Files
logos-logoscore-cli/docs/project.md
T
e48fc7fef3 Add logosctl alongside logoscore: one CLI for daemon, modules and packages (#76)
* feat(core_service): add refreshModules and cascade unload by default

Two runtime prerequisites for the logosctl merge.

refreshModules() wraps logos_core_refresh_modules(), which liblogos
documents as "call after installing new modules so they become
discoverable". Basecamp calls it on the package_manager install event,
which is why installing a module there needs no restart. core_service
did not expose it, so a CLI that installs a package had no way to make
the daemon see it short of a restart.

unloadModule() now takes withDependents and the CLI defaults it to true
(--no-dependents opts out). logos_core_unload_module already accepted
the flag; core_service hardcoded false, which left dependents running
against an unloaded provider. The result now carries dependents_unloaded
so the cascade is reported rather than silent.

The dispatch entry defaults a missing second argument to true, so a
one-argument unloadModule call keeps working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(flake): bundle package_manager and package_downloader

logoscore bundled only capability_module, so it could authenticate but
not manage packages — that was lgpd's and lgpm's job, as separate
binaries. Bundling the two package modules is what lets one binary do
the whole job.

Same trio logos-basecamp bundles, assembled the same way (map the
install bundler over the module libs), so the CLI and the GUI drive an
identical module surface rather than the CLI being a reduced sibling.
Only the package manager ships a distinct lib-portable; the other two
are variant-agnostic, matching basecamp's split.

Verified against a real daemon: all three are discovered with no module
configuration, both package modules load, and package_downloader
resolves the live default catalog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(daemon): make the config dir a self-contained session

The daemon knew about ~/.logoscore only as a place to keep its own
state files; packages, trust material and persistence lived elsewhere
or nowhere. Now the config dir is the whole world for a session:

  <configDir>/modules   installed core modules (writable)
  <configDir>/plugins   installed UI plugins
  <configDir>/keyring   trusted signing keys
  <configDir>/cache     downloaded .lgx
  <configDir>/data      module persistence

so copying the directory carries the session's packages and its trust
assumptions with it, and two sessions can disagree about both.

<configDir>/modules joins the search path beside the bundled dir.
Without it an installed module would sit on disk that the daemon could
never see, and install-then-load could not work at all.

The bundled package modules are loaded at boot and pointed at these
directories -- the same four setters basecamp calls -- because every
package command is an RPC into them. All best-effort: a daemon that
cannot manage packages is still fully usable for loading and calling
modules, so none of it aborts startup.

Verified live: a bare daemon creates the tree, loads all three modules,
and reports the embedded packages via getInstalledPackages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(package): daemon-side install, upgrade and remove

Adds the mutating package operations the CLI never had, orchestrated
inside the daemon and exposed as core_service.planPackageOperation /
applyPackageOperation, plus the `package`, `catalog` and `key` command
groups on top.

Why daemon-side: package_manager gates destructive work behind a
listener-ack protocol with a 3-second deadline. Driving that from a
short-lived client would mean holding an event subscription open,
interleaving it with outbound calls, and winning a three-second race
across the RPC boundary. In-process the ack cannot lose that race, and
every client command stays thin and stateless.

plan/apply is split so `--dry-run` and the confirmation prompt see
exactly what apply will do -- the same dependency-change table basecamp
shows, including which running modules get stopped. Without -y and
without a TTY the operation is refused rather than assumed-yes, so a
script that forgot --yes fails loudly instead of silently uninstalling.

install/upgrade take dependencies, remove takes dependents, both by
default. Installing never loads: it puts files on disk, and only
modules already running beforehand are restarted afterwards.

Verified against the live catalog on a portable build: install
openmetrics; install chat_module pulling delivery_module in order;
re-install as a no-op; install then load with no daemon restart
(refreshModules); and removing delivery_module cascading through
chat_module with both stopped first.

One trap worth naming: LogosList{vec} does not wrap a std::vector the
way it wraps a scalar -- it yields an empty args array, and the module
sees a zero-argument call it cannot dispatch. The batch uninstall
builds its argument explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(config): replace the flag surface with a YAML session document

Configuration was ~20 flags plus two hand-written mini-grammars: a
`NAME=PROTOCOL[,k=v...]` parser that existed only to squeeze a nested
structure through a flag, and a per-flag defaults<config<CLI merge. Both
are gone. main.cpp drops from 943 to 531 lines.

Configuration now lives in the session:

  <configDir>/daemon/config.yaml   written by `daemon config set`
  <configDir>/client/config.yaml   written by `client config set`

and is never passed alongside an unrelated command, so `daemon start`
and every client command take the session exactly as it is on disk.
--config-dir is the one surviving flag, because it selects *which*
session to act on and so cannot itself live inside one.

The split is by audience: files a human edits are YAML, files the
daemon and modules own stay JSON (state.json, tokens, the auto token).
Converting through nlohmann::json means the existing validated
daemonConfigFromJson / clientStateFromJson keep doing the schema work.

Two traps fixed while wiring it up, both of the accept-then-ignore kind
that leaves an operator with no explanation:

  - A bare `modules: {core_service: [ ... ]}` sequence was silently
    skipped (only the `{transports: [...]}` spelling parsed). It is now
    accepted as shorthand.
  - Unknown top-level keys are rejected by `config set` and the error
    names the correct spelling, so `insecureTcp` no longer looks like it
    worked when the key is `insecure_tcp`.

Module search paths remain configurable via the `modules_dirs` key,
which is what replaces -m for tests and dev loops.

The eight CLI tests that covered deleted flags are rewritten against
the new surface: malformed YAML rejected without clobbering the
existing config, unknown keys named, set/show round-trip, and absent
config treated as defaults rather than an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): logosctl, with docker-style command groups

Renames the binary and reorganises ~20 flat hyphenated commands into
groups: daemon, client, module, token, package, catalog, key, plus
top-level aliases for the four verbs that cannot be confused with a
runtime module (status, call, watch, stats) and the two package verbs
with no module meaning (install, search).

The hyphenated names survive as internal dispatch tokens but are hidden
from --help: `module load X` is rewritten to `load-module X` in argv
before CLI11 parses. The rewrite happens in argv rather than via nested
CLI11 subcommands because daemonSub->fallthrough() pushes a nested
subcommand's unmatched arguments up to the top level, where they are
rejected ("The following argument was not expected: show").

`module` is no longer an alias for the verbose call syntax -- it is the
group. Use `call`.

Also implements --detach, which was specified but missing. It re-execs
rather than continuing in the forked child: macOS refuses to let a
process that has already initialised CoreFoundation keep running after
fork(), and the Qt/liblogos link pulls CoreFoundation in before main.
The child redirects stdio to <configDir>/daemon/daemon.log -- without
that the shell never sees EOF and `daemon start --detach` appears to
hang -- and the parent returns only once state.json exists, so the next
command cannot race the boot.

Env vars and the default session directory rename to LOGOSCTL_*
and ~/.logosctl. User-facing messages now name the group grammar rather
than the internal tokens.

Verified on the portable build: daemon start --detach returns in ~3s
with a working daemon; catalog ls, search, install --dry-run, install,
package ls, module ls/load/show, upgrade (no-op), and remove of a
loaded module all behave. 18/18 CLI tests, unit tests unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: rewrite for logosctl, sessions and YAML config

The README documented a flag surface that no longer exists
(--persist-config, --module-transport, --modules-dir, the seven
--client-* flags) and had no account of sessions or packages at all.

Replaces the daemon/transport/persist-config sections with: what a
session directory is and why it is portable, `daemon config set` and
the YAML schema, and the package/catalog/keyring commands. Keeps the
two hard-won warnings that are still true -- a remote daemon must expose
capability_module as well as core_service, and plaintext tcp on a
non-loopback host needs an explicit opt-in.

Doctests are renamed and rewritten around sessions: the daemon spec no
longer passes -m but seeds ./session/modules, and uses
`daemon start --detach` instead of backgrounding with & (which returned
before the transports bound and raced the first command).

Also fixes the stats table: the MODULE column was a fixed 12 characters,
so a real name like "test_basic_module" ran straight into the PID with
no separator.

Verified the rewritten daemon-doctest sequence by hand against a dev
build: seed session, start --detach, module ls/load, call, stats, stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* doctests: use --detach and the session log

`daemon start --detach` already returns only once the daemon is
accepting commands and sends its output to <session>/daemon/daemon.log,
so the `sh -c '... > logs.txt 2>&1 &'` wrapper is not just redundant --
it hid the output the specs then tried to cat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: ship logosctl alongside logoscore instead of replacing it

logosctl is new and unvalidated; logoscore is what people depend on
today. Replacing one with the other in a single step meant every
consumer had to move at once, on trust. Shipping both means logosctl
can be validated in real use first, and logoscore removed afterwards.

Both binaries build from this repo over one shared runtime -- daemon,
core_service, client, output. They differ only in main.cpp and a
Config::Flavor the front-end sets, which selects the config directory,
the env var consulted for an override, the config file names, and the
format they are written in.

The isolation is the point, so it is deliberate and tested:

  logoscore  ~/.logoscore   LOGOSCORE_CONFIG_DIR  daemon/config.json
  logosctl   ~/.logosctl    LOGOSCTL_CONFIG_DIR   daemon/config.yaml

A logosctl session cannot disturb a logoscore deployment. Reading needs
no branch -- YAML is a superset of JSON, so one parser handles both --
only writing differs.

logoscore is behaviourally unchanged, which took two specific
decisions:

  - The session directory and the package-module bootstrap are gated on
    the modern flavor. Auto-loading two extra modules would change what
    `status` and `list-modules` report, and logoscore's doc-tests assert
    those exact counts.
  - The bundled package modules live in modules-pkg/ rather than
    modules/, because logoscore scans the latter and would otherwise
    report two modules it never had.

Verified: `logoscore --help` is the old flat surface with all four flag
families intact; a logoscore daemon reports loaded:1 not_loaded:0 and
creates no session directories; both daemons run at once with separate
state.

Its doc-tests are restored unchanged. logosctl gets its own, including
a new logosctl-packages spec covering the capability that motivated the
merge -- search, dry-run, install, load, remove -- verified end to end
against the live catalog.

122/129 unit tests, 18/18 CLI tests. The 7 OutputTest failures are
pre-existing on master.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build: give logosctl its own flake outputs

Building both binaries into one package meant `nix build` and `.#cli`
started handing out logosctl too, which is the opposite of keeping the
two apart while the new one is validated.

Now each output ships exactly one binary:

  .#cli  .#cli-bundle-dir  .#cli-appimage   ->  logoscore
  .#ctl  .#ctl-bundle-dir  .#ctl-appimage   ->  logosctl
  .#     (default)                          ->  logoscore

So anything already pointing at the default or at `.#cli` -- including
every doc-test across the workspace that does
`nix build github:logos-co/logos-logoscore-cli` -- keeps getting the
tool it gets today, and logosctl is strictly opt-in.

They still compile together, since they share everything but main.cpp;
only the packaging is split. modules-pkg/ ships solely in the ctl
outputs, because logoscore never scans it.

logoscore's desktop entry and icon are restored, and logosctl gets its
own. The logosctl doc-tests now build .#ctl / .#ctl-bundle-dir.

Verified: every output builds and ships only its own binary; both
portable bundles run and report the module set expected of each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(config): let each session subdirectory be redirected

The session directory being self-contained is what makes it portable, and
that should stay the default -- but it was also the only option, which
made reasonable setups impossible: sharing one keyring across sessions,
putting the .lgx cache on a bigger disk, or pointing at a modules tree
something else manages.

A `dirs:` block now redirects any of them:

    dirs:
      keyring: ~/.config/logos/trusted-keys
      cache: /var/cache/logos
      modules: /opt/logos/modules
      plugins: plugins-custom
      data: /var/lib/logos/data

The form of the value decides whether portability survives, which is the
part worth knowing:

    plugins-custom  -> <session>/plugins-custom   still portable
    ~/x             -> $HOME/x                    outside the session
    /var/cache/...  -> as given                   outside the session

`~` is handled because it is the natural thing to write in a config file
and would otherwise resolve to <session>/~/... , which exists nowhere.

Overrides resolve once, when set, so relocating a session afterwards
cannot silently drag an absolute path along with it. Only the daemon
applies them, and it does so before anything asks Config for a path.

persistence_path is folded into dirs.data -- it was already the same
setting under an older name -- so the two no longer need choosing
between at the point of use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(daemon): rotating log file with configurable size and retention

--detach used to dup2 stdout/stderr straight onto daemon/daemon.log,
which grew without bound and had no rotation. A long-lived daemon needs
better than that.

There is now a logs/ directory, like Basecamp's, and a logging block:

    logging:
      enabled: true          # false -> no log file at all
      file: daemon.log       # inside dirs.logs
      max_size_mb: 10        # rotate past this; 0 = never rotate
      max_files: 5           # keep this many in total
      console: true          # mirror to the terminal

dirs.logs joins the overridable session directories, so logs can be
shipped somewhere a collector already watches.

Capture is pipe-based rather than a file redirect, and that is the
load-bearing decision: module hosts are separate processes holding
inherited descriptors. Redirecting to a file catches their output but
makes rotation impossible -- renaming a file out from under a child that
has it open just keeps filling the old inode. A pipe puts one reader in
charge, so rotation is safe and subprocess output still lands in the
log. Same shape as basecamp's LogRedirector, which solved this already.

The size cap and retention come from spdlog's rotating sink rather than
being hand-rolled; liblogos already logs through spdlog. Lines arriving
from the pipe already carry their own timestamp and level, so the sink
uses a raw pattern instead of stamping them twice.

Two bugs found while testing it:

  - Draining raced shutdown. stop() cleared the running flag before
    restoring the descriptors, so a reader holding data would process
    it, loop, see the flag clear and exit -- dropping whatever was still
    in the pipe. The last lines before a shutdown are exactly the ones
    worth keeping. EOF is now the only stop condition.
  - --detach reported the wrong path. The parent prints before the child
    has read the config, so it guessed the default and lied to anyone
    who had redirected dirs.logs or renamed the file. It now reads the
    same config the child will.

Verified live: default, disabled, and redirected-with-custom-filename
all behave and are reported accurately. Four unit tests cover capture
of both streams, no double-stamping, rotation with retention, and
disabled-is-not-an-error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(daemon): timestamp log files, and bound the directory

Adopts basecamp's naming -- each start writes its own
daemon_<yyyymmdd_HHMMSS>.log -- so a session's output is one file you can
point at, instead of every run appending into the same daemon.log.

Two things beyond copying basecamp:

  - `logging.file` survives as a symlink to whichever file is current, so
    `tail -F logs/daemon.log` follows across restarts and nobody has to
    work out a stamp. It also means --detach can report a path that is
    always valid; previously it had to guess one, and guessed wrong for
    anyone who had redirected dirs.logs.

  - max_files now bounds the *directory*, pruning oldest-first at each
    start. spdlog's retention only prunes within one sink's rotation set,
    and every start opens a new stamped base name, so without this a
    daemon restarted a hundred times would leave a hundred logs behind.
    basecamp has exactly that problem.

Verified live: three restarts leave three stamped files with the symlink
tracking the newest; five restarts with max_files: 2 leave two.

Two new tests cover the naming and the symlink resolving to the current
session, and the cross-session pruning. The rotation test needed fixing
too -- it counted the symlink as a log file, which predated the symlink
existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: ignore suffixed nix out-links

.gitignore listed `result` but not `result-*`, so every out-link from a
targeted build -- `nix build '.#ctl' -o result-ctl`, `-o result-tests`,
and so on -- was untracked-but-not-ignored, and `git add -A` committed
them as symlinks into /nix/store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: keep releasing logoscore, and release logosctl beside it

The earlier rename left the release workflow building the `cli-*`
outputs -- which are logoscore -- while naming every artifact
`logosctl-*`. A release/** push would have shipped logoscore binaries
under the wrong name, and stopped releasing logoscore under its own.

Both are now built and published as separate, correctly-named assets:

  logoscore-{x86_64,aarch64}-linux.tar.gz   from .#cli-appimage
  logoscore-aarch64-macos.tar.gz            from .#cli-bundle-dir
  logosctl-{x86_64,aarch64}-linux.tar.gz    from .#ctl-appimage
  logosctl-aarch64-macos.tar.gz             from .#ctl-bundle-dir

logoscore's asset names are exactly what they were, which matters:
release sets fetch this repo and expect a bundle containing
`bin/logoscore`. Each tool builds from its own flake outputs, so an
asset labelled logoscore contains logoscore and nothing else.

Both jobs gained a tool matrix with fail-fast disabled, so a failure in
the under-validation logosctl cannot block a logoscore release. The
release job now collects artifacts by pattern instead of naming each
one, so retiring logoscore later means deleting a matrix entry rather
than unpicking a download list.

Release notes lead with logoscore as the tool to use, and say the two
share no state so installing logosctl cannot disturb an existing setup.

The doc-tests workflow globs doctests/*.test.yaml, which now covers both
suites, so it is no longer named after one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: run both tools' suites, in parallel

While both binaries ship, both get tested. logoscore had no automated
coverage on this branch at all -- only its doc-tests -- so a change to
the shared runtime could regress the tool people actually use and
nothing would say so.

tests/test_cli_logoscore.cpp and tests/test_integration_logoscore.cpp
are copies of the suites frozen against logoscore's surface. Copies
rather than a parameterised shared suite on purpose: the two surfaces
genuinely differ, and this way retiring logoscore is a delete rather
than an unpick.

checks.tests-logosctl and checks.tests-logoscore are separate
derivations, so nix builds them concurrently; checks.tests aggregates
both, keeping `nix build .#checks.<sys>.tests` working for CI while now
covering both tools.

It immediately earned its keep, catching three regressions:

  - The integration harness still passed -m, which logosctl no longer
    accepts, so its daemon never started and seven integration tests
    were failing on this branch. It now writes the modules_dirs config
    the daemon reads.

  - `logoscore --version` reported "logosctl version ...". The version
    banner had been renamed wholesale; each front-end now names itself.
    Exactly the sort of thing nobody notices until a bug report cites
    the wrong tool.

  - The new log sink only mirrored to the console when stdout was a
    TTY, so `logoscore -D > logs.txt` -- which the doc-tests do --
    produced an empty file. Mirroring now follows the configured
    setting, pipe or terminal alike, and the log file is gated to
    logosctl so logoscore's output behaviour is untouched.

Both suites green: logosctl 138 unit + 18 CLI + 18 integration,
logoscore 20 CLI + 18 integration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(package): honour -o, and stop parsing command lines backwards

`package download -o DIR` accepted the flag and threw it away -- the
argument was parsed into a variable and then explicitly discarded with
`(void)outDir;`. The file went to $TMPDIR regardless. The config's
`dirs.cache` had the same problem from the other end: the directory was
created and documented as holding downloads, and nothing ever wrote to
it.

The cause was the same for both. package_downloader takes no
destination, so the file lands in $TMPDIR on the DAEMON's filesystem --
which is where the move has to happen too. Doing it client-side would
work only for a local daemon. So `downloadPackage` joins the daemon-side
package operations: it downloads, then moves the result into the
requested directory, or into the session's cache/downloads when no -o
was given. The client resolves a relative -o against its own working
directory first, so a local daemon does what the user typed; against a
remote one the path is remote, and a bad one fails loudly rather than
quietly writing elsewhere.

Writing the first test for it turned up something worse. CLI11's
`parse(std::vector<std::string>&)` consumes the vector from the BACK --
only the rvalue overload reverses for you -- so passing natural order
parses the command line backwards. `watch` and `issue-token` did reverse
first; nothing else did. It goes unnoticed with one positional and flags
(order does not matter), and is quietly wrong the moment an option takes
a value, because the option pairs with the token to its LEFT:

  package download pkg -o dir   ->  name="dir", output="pkg"
  package install a b --version 1.0
                                ->  names=["1.0","b"], version="a"

So `package install`, `search --category`, and `download -o` all
misparsed. Every site now goes through one `parseArgs` helper that
reverses, which fixes the broken ones, is a no-op for the harmless ones,
and removes the trap for the next command.

PackageCommand had no unit tests at all, which is why a discarded flag
survived review. Four now cover download; the two asserting -o reaches
the daemon fail against the old code.

142 unit + 18 CLI + 18 integration green for logosctl, 20 + 18 for
logoscore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: one README about the repo, one document per tool

The README had grown into a logosctl manual with a banner on top telling
logoscore users that everything below did not apply to them, and pointing
them at doc-test YAML for their actual documentation. Since logoscore is
still the tool to use, its documentation should not be the thing you are
told to skip.

So: README.md covers what is true of both -- what the repo is, the two
binaries and how they differ, the flake outputs, the test targets,
dependency resolution, platforms -- and hands off to one document per
tool.

  docs/logoscore.md   the usage material, unchanged, as its own document
  docs/logosctl.md    sessions, config, logs, packages, examples

Writing logosctl's own document exposed a gap: it had no command
reference at all. The rewrite dropped the client-command list, argument
typing and exit codes, and left behind a "see Argument typing below"
pointing at a section that no longer existed. All three are back, with
the command list written against the grammar that is actually
implemented (checked against normalizeGroupVerbs and the subcommand
dispatch, not from memory), plus the two defaults worth stating up front
-- install does not load, remove takes dependents.

Also fixes stale copy that survived the earlier rewrite: `load-module`
where logosctl says `module load`, and a "multiple module directories"
caption over a --config-dir example, from a flag logosctl does not have.

Deleting logoscore later is now deleting one file and a table row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(daemon): make TLS configurable again, and say why startup failed

Three bugs, all found by running the doc-tests I had just rewritten
instead of trusting them.

**tcp_ssl could not be configured at all.** `transportFromJson` never
read `cert` or `key`. That was harmless while those arrived via
`--module-transport ...,cert=...,key=...`, parsed by the CLI mini-grammar
-- but that grammar is gone, and the config file is now the only place to
set them. So every tcp_ssl listener bound with no certificate: the daemon
started, reported itself healthy, accepted connections, and failed every
handshake with "no shared cipher (SSL routines)". The client just saw
"core_service not reachable".

The stripping was deliberate but applied one layer too high: cert and key
have no business in state.json, which clients read, but the config file is
where an operator *authors* them. `transportToJson` now takes
`includeSecrets` -- true writing the config, false writing state.json. A
test asserts the round-trip, and another asserts the key path never
appears in state.json.

**`--detach` swallowed the reason startup failed.** Config validation runs
before LogSink opens the log, and the child's stderr went to /dev/null, so
a rejected config produced "daemon exited during startup. See
<path>/logs/daemon.log" -- naming a file that had never been created. The
child's early output now goes to a startup file the parent reads and
prints on failure, removed either way. LogSink takes those descriptors
over as soon as it starts, so the file only ever holds pre-logging output.

**The plaintext-TCP guard advertised a flag that does not exist.** It said
"pass --insecure-tcp"; logosctl has no such flag. It now names the config
key, `insecure_tcp: true`.

Verified end to end against a real daemon: plaintext guard refuses and
says why, loopback TCP binds and serves `status`/`module ls` from a
separate client session, TLS serves the same over 6443/6444, and dropping
the CA while keeping verify_peer still fails closed.

144 unit + 18 CLI + 18 integration green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(flake): give autoPatchelf the libraries both binaries now link

Every Linux build failed:

  auto-patchelf could not satisfy dependency libyaml-cpp.so.0.8
  wanted by .../bin/.logoscore-wrapped

The packaging derivations listed only Qt in buildInputs, which is what
autoPatchelfHook resolves DT_NEEDED entries against. yaml_json.cpp and the
log sink are in the shared sources, so *both* binaries link yaml-cpp and
spdlog -- including logoscore, which is why its Linux build broke too on a
branch that was supposed to leave it alone.

macOS does not patchelf, so this was invisible locally and in the macOS
CI jobs; only the Linux matrix caught it, and it took down the AppImage
builds, the CI job, and every Linux doc-test with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(doctests): bring the logosctl specs up to what logosctl does

Nineteen doc-test steps were failing. None of them were runtime bugs in
the specs' own right -- they were specs still describing an older
logosctl, which is its own kind of failure: a doc-test that lies is worse
than no doc-test.

  transports      Still drove `--module-transport` and hand-written
                  client/config.json. The flags had been dropped from the
                  `run:` lines but no config step replaced them, so the
                  daemon never bound TCP at all and every step after it
                  failed. Rewritten around `daemon config set` /
                  `client config set` with YAML documents, for both the
                  plaintext and TLS halves.

  daemon          Read the log at session/daemon/daemon.log; logs moved to
                  session/logs/. The crash-recovery step passed `-m`,
                  which logosctl does not accept, so its daemon never
                  started and the step reported LEAKED against a worker
                  that had never existed.

  modules-bundle  Asserted all three modules in result/modules. The
                  package modules live in modules-pkg/ so that logoscore's
                  modules/ stays byte-identical -- which the spec is now
                  the place that explains.

  packages        Expected the interactive wording ("dry run",
                  "Installed:"). Doc-tests are not a terminal, so every
                  command renders JSON. The install was working the whole
                  time; only the assertions were wrong. They now match the
                  JSON, and the prose says why it is JSON.

Rewriting the transports spec is what turned up the TLS and --detach bugs
fixed in the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(config): a typo must not abort the daemon, and a key must not lie

Three defects in the YAML config path, all found by building a Python
client against this CLI and checking its assumptions against the binary
rather than the docs.

**A config typo aborted the process.**

  printf 'version: 2\nmodules_dirs: /single/path\n' > bad.yaml
  logosctl --config-dir ./s daemon config set ./bad.yaml
  => libc++abi: terminating due to uncaught exception ...
     [json.exception.type_error.302] type must be array, but is string

nlohmann's `json::value(key, default)` THROWS when the key is present
with the wrong type, every config read used it, and nothing caught it.
So it was not one key -- it was every key in both readers. A scalar
where a list belongs is an ordinary mistake and it killed the binary.

Now a type-checked reader (src/json_schema.h) records
"<dotted.path>: expected <what>, but got <what>" and the document is
refused whole, the same shape as the existing unknown-key error:

  {"code":"INVALID_CONFIG",
   "message":"modules_dirs: expected a list of strings, but got a string."}

Both readers went through it, including two paths that could abort the
daemon mid-boot rather than at `config set`.

**`config set` validated after writing.** A schema-invalid document was
installed and then reported as an error, leaving the session holding a
config the daemon would refuse to boot from. Validation now happens
entirely in memory first, on both the daemon and client sides -- the
client side had no schema validation at all -- and the write is
temp-file + rename instead of truncate-in-place.

That exposed a fourth: `yaml_json::dump` emitted numeric-looking strings
bare, so `port: "6001"` came back as the number 6001. The bytes
validated were not the bytes written.

**Two keys were accepted, stored, and never applied.**

`signature_policy` sat on the allowlist and was written verbatim to
config.yaml but was never even parsed. An operator setting `require` got
no enforcement and no warning. It is now parsed with a strict allowlist
and pushed into package_manager at boot beside setKeyringDirectory --
the module has had setSignaturePolicy all along. Unset issues no RPC, so
the module keeps its own default instead of having it restated.

The top-level `ssl: {cert, key, ca}` block was parsed into DaemonConfig
and read by nobody; only per-listener cert/key reached the transport
set. Configuring TLS the obvious way therefore produced listeners with
no certificate and "no shared cipher" on every handshake -- the same
failure fixed one layer down last commit. It is now a session-wide
default that per-listener values override.

Also: docs advertised `module load --no-deps`, which does not exist --
`module load` takes only a positional name and always resolves
dependencies. Corrected, along with the rest of the command reference,
verified against the binary.

logoscore is untouched: 20 CLI + 18 integration, exactly as before.
logosctl 171 unit (was 144) + 25 CLI (was 18) + 18 integration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(detach): re-exec the launcher, not the ELF it hides

`daemon start --detach` was dead on Linux portable builds. The daemon
exited immediately with status 127, no output, and no log file -- so the
only diagnostic was "daemon exited during startup. See <path>", naming a
file that had never been created.

strace, on a real Linux box, said it in one line:

  execve(".../bin/.logosctl.elf", [...]) = -1 ENOENT
  exit_group(127)

A portable bundle installs the CLI as a launcher script beside a hidden
companion:

  bin/logosctl        the launcher, a shell script
  bin/.logosctl.elf   the real ELF

The launcher exists because that ELF cannot be started on its own: its
PT_INTERP names a dynamic loader that is not on the host, so the launcher
runs it through a known-good ld.so instead. The ENOENT is the kernel
reporting the missing *interpreter* -- the ELF is right there.

--detach re-execs itself (it has to: macOS forbids running a forked
process that has initialized CoreFoundation), and it re-exec'd
executablePath(), which is that ELF.

My first attempt preferred argv[0], reasoning that it is what the caller
actually typed. That was wrong, and the trace showed it failing
identically: the launcher execs ld.so with the ELF, ld.so drops itself
from argv, and the program sees the ELF as argv[0] too. Neither source
of truth names the launcher.

So the mapping is applied to whatever candidate we end up with, using the
convention the launcher script itself documents -- the install dir is the
one holding the hidden companion `.$BASE.elf`. `bin/.logosctl.elf` maps
back to `bin/logosctl`. argv[0] is still preferred over
executablePath() (it is what was invoked, and it is right when a bare
name resolves through PATH), and it is absolutised, since the daemon may
run from a different directory.

Only this combination was ever broken: portable AND Linux AND --detach.
macOS bundles a real binary with qt.conf and no launcher, Linux dev
builds are ordinary ELFs, and the foreground -D path never re-execs. The
one doc-test that uses the portable bundle is the packages spec, and
cachix served a permanent 522 for one of its store paths from the day it
was written -- so its 14 cascading failures read as infrastructure until
the cache recovered and the real failure surfaced underneath.

Verified on Linux against the same bundle that failed: daemon starts
detached, all three bundled modules load, `daemon stop` returns ok.

Also here, and what made the diagnosis possible: --detach now prints the
TAIL of the daemon log rather than its path. The startup file only holds
output from before LogSink takes the descriptors, so a daemon that dies
after logging is up left it empty and the reason unread. That there was
no log at all is what pointed at exec.

179 unit tests (8 new, covering the launcher mapping and its edges: no
sibling, an ordinary foo.elf, a non-executable candidate, absent argv[0]).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build: bundle logosctl/logoscore as headless Qt programs

* build: bump nix-bundle-dir and nix-bundle-appimage to main

Picks up the merged trampoline drop: per-arch psABI PT_INTERP, DT_RPATH,
qtCliApp for headless Qt, and the AppImage consumer that already tracks
the same pin. nix-bundle-dir 4fd87d1 (PR tip) → cb9afc8; appimage
8fcc56b → 04a3cf8.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 21:49:15 -03:00

55 KiB

Logosctl CLI — Project Description

Overview

The logosctl CLI is a standalone application that provides the command-line interface for the Logos Core runtime. It depends on liblogos, a C library that provides the core runtime (plugin discovery, loading, dependency resolution, event loop). The CLI is responsible for:

  • Running as a daemon that hosts the liblogos runtime
  • Providing client commands that talk to the daemon via RPC

This project will live in its own repository. liblogos is consumed as an external C library dependency.

Project Structure

logosctl-cli/
├── src/                              # All CLI source code
│   ├── main.cpp                      # Entry point — detects mode, dispatches
│   ├── config.cpp/h                  # Token + config file resolution
│   │
│   ├── daemon/                       # Daemon path (logosctl daemon start)
│   │   ├── daemon.cpp/h              # Start core, load core_service, run event loop,
│   │   │                             # open each --module-transport listener
│   │   ├── daemon_state.cpp/h        # DaemonConfig (config.json) + DaemonRuntimeState
│   │   │                             # (state.json) — operator preferences (writes only
│   │   │                             # on --persist-config) + live runtime state.
│   │   └── token_store.cpp/h         # Named-token table — TokensFile owns daemon/tokens.json
│   │                                 # (hashed entries) + raw daemon/tokens/<name>.json
│   │
│   ├── client/                       # Client path (all subcommands)
│   │   ├── client.cpp/h              # Read <configDir>/client/config.json, connect to
│   │   │                             # daemon's core_service via LogosAPIClient
│   │   ├── output.cpp/h              # Output formatter (human / JSON / NDJSON)
│   │   └── commands/                 # Subcommand implementations
│   │       ├── command.cpp/h         # Base command class
│   │       ├── status_command.cpp/h
│   │       ├── load_module_command.cpp/h
│   │       ├── unload_module_command.cpp/h
│   │       ├── reload_module_command.cpp/h
│   │       ├── list_modules_command.cpp/h
│   │       ├── module_info_command.cpp/h
│   │       ├── call_command.cpp/h
│   │       ├── watch_command.cpp/h
│   │       ├── stats_command.cpp/h
│   │       ├── stop_command.cpp/h
│   │       ├── issue_token_command.cpp/h    # Mints named tokens (daemon/tokens/<name>.json)
│   │       ├── revoke_token_command.cpp/h   # Revokes by name
│   │       └── list_tokens_command.cpp/h    # Lists issued tokens (name + metadata, no plaintext)
│   │
│   └── core_service/                 # Built-in module — CLI ↔ daemon RPC gateway
│       ├── core_service_impl.h       # LOGOS_PROVIDER class with LOGOS_METHOD declarations
│       ├── core_service_impl.cpp     # Method implementations (delegates to liblogos C API)
│       ├── core_service_loader.h     # LogosProviderPlugin loader
│       ├── metadata.json             # Plugin metadata
│       └── core_service_dispatch.cpp    # Manual callMethod/getMethods dispatch
│
├── tests/
│   ├── test_core_service.cpp         # core_service method tests
│   ├── test_cli_commands.cpp         # Mode detection, subcommand dispatch
│   ├── test_cli_output.cpp           # Output formatter tests
│   ├── test_cli_daemon.cpp           # Daemon lifecycle, state file
│   └── test_cli_client.cpp           # Client connection to core_service
│
├── docs/
│   ├── spec.md                       # CLI specification (user-facing behavior)
│   └── project.md                    # This file (implementation details)
│
├── CMakeLists.txt                    # Build configuration
├── flake.nix                         # Nix flake
└── nix/                              # Nix build modules

Dependencies

Dependency Type Purpose
liblogos C library (external) Core runtime: plugin discovery, loading, dependency resolution, event loop, process stats
logos-cpp-sdk C++ library (external) RPC client/provider classes: LogosAPI, LogosAPIClient, LogosProviderBase, TokenManager
logos-cpp-generator Build tool (external) Code generator for LOGOS_METHOD dispatch tables
Qt6 Core Framework Event loop, JSON handling, process management
Qt6 RemoteObjects Framework IPC between daemon and module host processes
CMake 3.14+ Build system
Google Test Test framework
Nix Package manager Reproducible builds

liblogos C API surface used

The CLI uses these functions from liblogos (declared in logos_core.h):

Function Used by
logos_core_init(argc, argv) Daemon
logos_core_add_modules_dir(path) Daemon
logos_core_start() Daemon
logos_core_exec() Daemon
logos_core_cleanup() Daemon
logos_core_load_module(name, true) Daemon, core_service
logos_core_unload_module(name, false) core_service
logos_core_get_known_modules() core_service
logos_core_get_loaded_modules() core_service
logos_core_get_modules_info() core_service
logos_core_get_module_stats() core_service

CLI Execution Paths

The logosctl binary detects its mode from the first argument and dispatches to one of two paths:

logosctl daemon start / daemon         →  Daemon path    (long-running, hosts modules)
logosctl <subcommand>        →  Client path    (short-lived, talks to daemon)

Detection logic (main.cpp)

Before mode detection, main() scans argv for -v/--verbose and installs a custom Qt message handler that suppresses debug/info/warning logs unless verbose is set.

if argv contains "-D" or "daemon"      → daemon path
else if argv[1] is a known subcommand  → client path
else if argv contains -m/-p (no -D)    → error (inline mode removed)
else                                   → print help

Daemon Path (logosctl daemon start)

main.cpp
  → Daemon::start(modulesDirs, persistencePath, transportInfos)
    1. Generate instance ID, set LOGOS_INSTANCE_ID env var
       Refuse to start if a live daemon already owns this config-dir: read
       daemon/state.json and, if its pid is still alive (kill(pid,0)), exit 1.
       A stale state.json from a crashed daemon (pid gone) is not a live
       owner and is overwritten normally. This runs before logos_core_init so
       a duplicate launch fails fast instead of spawning module hosts first.
    2. logos_core_init(argc, argv)
    3. logos_core_add_modules_dir() for each -m path
    4. logos_core_start()                         // discover modules
    5. Register core_service (and capability_module) in-process via
       LogosAPI/LogosAPIProvider. For each --module-transport NAME=PROTOCOL[,k=v]
       flag, pass the resolved TransportInfo to LogosAPIProvider so it opens
       one listener on the named module (local + tcp + tcp_ssl can coexist).
    6. Mint the auto token, hash it into tokens.json["tokens"], emit raw
       value into client/auto.json, register hashes with TokenManager
    7. Write <configDir>/daemon/state.json       // resolved listeners + instance_id
       (Tokens already persisted to daemon/tokens.json by step 6.)
       Write <configDir>/client/config.json      // local-default dial spec (first-boot only)
       If --persist-config: write <configDir>/daemon/config.json (operator intent)
    8. Print startup message to stdout
    9. logos_core_exec()                          // Qt event loop (blocks)
   10. On SIGINT/SIGTERM or shutdown RPC:
       logos_core_cleanup()
       Remove daemon/state.json (tokens.json + config.json survive)
       exit(0)

The daemon path calls the liblogos C API directly. It owns the runtime and hosts all modules, including the built-in core_service module. Startup/shutdown messages go to stdout (so > logs.txt works); debug logs go to stderr and are suppressed unless --verbose is passed.

Multi-transport. --module-transport is repeatable, scoped per module (well-known or user-configured). When the daemon exposes a module over several transports at once, each one becomes an entry under that module's transports array in daemon/state.json, and the provider maintains one listener per entry.

Local is always present. Every configured module — well-known or user — implicitly carries a LocalSocket listener prepended to its resolved set, even when the operator only passed --module-transport NAME=tcp,.... The operator's TCP / TCP+SSL flags add additional outside-facing listeners; they don't replace the same-host LocalSocket. This is what keeps module ↔ module traffic working on the local socket in every configuration: the parent's notifyCapabilityModule handshake, the SDK's auto-requestModule flow inside LogosAPIClient, and any cross-module getClient(name) calls all default to LocalSocket and have no plumbing to discover the operator's chosen TCP endpoint — forcing a LocalSocket listener alongside whatever else the operator named keeps those paths working without fan-out. The advertised transports[] array always lists the LocalSocket entry first, followed by operator-named entries in the order they were typed.

Plaintext-TCP guard. Plaintext tcp listeners on a non-loopback host expose tokens in cleartext. The daemon refuses to bind such a listener unless --insecure-tcp was passed.

IPv6 bind targets. Ephemeral-port allocation (used when a transport asks the kernel to pick a port) binds on the address family that matches the host: an IPv6 literal such as :: or ::1 allocates on AF_INET6, IPv4 literals on AF_INET. (Previously the allocator was IPv4-only and returned 0 for any IPv6 host, aborting daemon startup for IPv6 TCP transports.)

Strict port parsing. A --module-transport ...,port=<n> value must be a whole valid integer — trailing garbage (6000x) or hex (0x1F90) is a hard error (exit 1), not a silently-wrong or auto-allocated 0 port.

Named tokens. TokenStore (owned by the daemon) persists the issued-token table inside <configDir>/daemon/tokens.json["tokens"] ({name, hash, issued_at, expires_at, local_only} rows; hashes are SHA-256 hex). The auto token's hash lands there too at boot, with its raw value emitted to client/auto.json. Named tokens from issue-token --name <n> are additionally written to daemon/tokens/<n>.json for distribution to a specific client; once copied to the target host, the daemon-side raw file may be deleted because validation runs against the in-memory map seeded from the hashes.

Client Path (logosctl <subcommand>)

main.cpp
  → Client::connect()
    1. Read <configDir>/client/config.json (dial spec + instance_id + token_file)
    2. Set LOGOS_INSTANCE_ID env var from instance_id
       → now LogosInstance::id("core_service") returns the correct registry URL
    3. Read the raw token from token_file (or LOGOSCTL_TOKEN env var if set)
    4. Build LogosTransportConfig from the dial spec (endpoint/host/port/codec
       and cert/key/ca/verify_peer for TLS) — applied per-connection only,
       never installed as a process-wide default (the SDK's LogosAPIProvider
       reads the global default to bind its own server socket, so flipping
       the default would try to bind a TLS server with no cert/key and abort)
    5. Create LogosAPIClient targeting "core_service" with that explicit
       transport config (LogosAPI itself stays on the local-socket default)
    6. Authenticate with token
  → Command::execute(args)
    1. Call LOGOS_METHOD on core_service via LogosAPIClient
    2. Format result (human / JSON)
    3. Print to stdout, exit

Client commands never call liblogos C API functions, and they never read daemon/state.json. They talk exclusively to the daemon's core_service module via the SDK's RPC mechanism, using whatever dial spec client/config.json provides. This means the client path depends only on logos-cpp-sdk, not on liblogos.

Liveness is no longer a separate pre-check. The previous PID-alive probe only worked for local daemons — it's meaningless for a daemon in a container or across NAT. The first RPC (commonly status) surfaces connect failures through the same timeout/error path as any other method, so there's one error story. DaemonRuntimeStateFile::read().fileOk now just reflects "file exists and parses" — the on-disk precondition, not liveness. The status command does opportunistically kill(pid, 0) against state.json's pid for fast same-host stale-state detection, but that's a short-circuit before falling through to the same RPC path.

Inline Path (removed)

The legacy inline path (logosctl -m -l -c "module.method(args)" --quit-on-finish) started the core in the same short-lived process, loaded modules, executed the -c calls directly via the C API, and exited. It has been removed — use a daemon (-D) plus load-module / call client subcommands instead. The daemon starts clean; -m/--persistence-path configure daemon startup only (the -l/--load-modules autoload flag was also removed).


CoreService Module

The core_service module is the RPC gateway between CLI clients and the daemon. It is a proper Logos module built with the new SDK API (LOGOS_PROVIDER, LOGOS_METHOD), but it lives in the CLI codebase (not in liblogos) because it is the CLI's concern — it exists to serve CLI clients.

Why a module?

  • Uses the same SDK API as any other module — no special plumbing
  • CLI clients connect to it via LogosAPIClient, same as module-to-module communication
  • Auth tokens work the same way (TokenManager validates the client token)
  • Events can be forwarded using the standard event system
  • If needed in the future, it could be extracted into a standalone plugin

Definition

Files: src/core_service/core_service_impl.h

#include <logos_provider_object.h>

class CoreServiceImpl : public LogosProviderBase
{
    LOGOS_PROVIDER(CoreServiceImpl, "core_service", "1.0.0")

public:
    // Module lifecycle
    LOGOS_METHOD QVariant loadModule(const QString& name);
    LOGOS_METHOD QVariant unloadModule(const QString& name);
    LOGOS_METHOD QVariant reloadModule(const QString& name);

    // Queries
    LOGOS_METHOD QJsonArray listModules(const QString& filter);
    LOGOS_METHOD QJsonObject getStatus();
    LOGOS_METHOD QJsonObject getModuleInfo(const QString& name);
    LOGOS_METHOD QJsonArray  getModuleStats();

    // Proxied call — delegates to target module
    LOGOS_METHOD QVariant callModuleMethod(const QString& module,
                                          const QString& method,
                                          const QVariantList& args);

    // Event forwarding
    LOGOS_METHOD bool watchModuleEvents(const QString& module,
                                       const QString& eventName);

    // Daemon lifecycle
    LOGOS_METHOD QJsonObject shutdown();

protected:
    void onInit(LogosAPI* api) override;

private:
    LogosAPI* m_api = nullptr;
};

How each method works

LOGOS_METHOD What it does (daemon-side)
loadModule(name) Calls logos_core_load_module(name, true). Returns {"status":"ok","module":"...","version":"...","dependencies_loaded":[...]}
unloadModule(name) Calls logos_core_unload_module(name, false). Returns {"status":"ok","module":"..."}
reloadModule(name) Checks if loaded/crashed → unload if needed → load. Returns result with previous_status. Non-destructive on failure: if the module was loaded before and the reload's load step fails, it attempts to restore the prior instance and reports restored: true/false plus an explanatory error rather than leaving the module down
listModules(filter) Calls logos_core_get_modules_info() (name + loaded flag + embedded metadata per module). Emits version from metadata + status enum. Returns JSON array
getStatus() Reads daemon state (PID, uptime, version) + calls listModules("all"). Returns {"daemon":{...},"modules_summary":{...},"modules":[...]}
getModuleInfo(name) Pulls the module's entry from logos_core_get_modules_info() (version from embedded metadata, dependencies, dependents) and, for loaded modules, methods/events via SDK introspection over RPC. Returns extended JSON
getModuleStats() Calls logos_core_get_module_stats(). Returns CPU/memory per module
callModuleMethod(module, method, args) Uses m_api->getClient(module)->invokeRemoteMethod() to proxy the call to the target module. Returns the result. LogosResult return values are unpacked into {success, value, error} here so that the JSON shape is identical regardless of whether the daemon-module hop went over the local socket (QRO) or the plain-C++ transport (tcp / tcp_ssl).
watchModuleEvents(module, event) Registers an event listener on the target module via m_api->getClient(module)->onEvent(). Forwards received events by calling emitEvent() on core_service, which the CLI client receives over its own event subscription
shutdown() Schedules QCoreApplication::quit() after a 200ms delay (to allow the RPC response to be sent), then the daemon performs its normal cleanup (unload modules, remove daemon/state.json, exit)

Loader

Files: src/core_service/core_service_loader.h

class CoreServiceLoader : public QObject, public PluginInterface, public LogosProviderPlugin
{
    Q_OBJECT
    Q_PLUGIN_METADATA(IID LogosProviderPlugin_iid FILE "metadata.json")
    Q_INTERFACES(PluginInterface LogosProviderPlugin)

public:
    QString name() const override { return "core_service"; }
    QString version() const override { return "1.0.0"; }
    LogosProviderObject* createProviderObject() override {
        return new CoreServiceImpl();
    }
};

Metadata

Files: src/core_service/metadata.json

{
  "name": "core_service",
  "version": "1.0.0",
  "type": "core",
  "category": "management",
  "description": "RPC gateway for CLI client commands"
}

Registration (daemon-side)

The daemon registers core_service as an in-process module during startup, before entering the event loop:

// In Daemon::start()
auto* coreServiceApi = new LogosAPI("core_service");
auto* coreServiceImpl = new CoreServiceImpl();
coreServiceImpl->init(coreServiceApi);

auto* provider = coreServiceApi->getProvider();
provider->registerObject("core_service", static_cast<LogosProviderObject*>(coreServiceImpl));

This registers the module directly into the runtime using the C++ SDK classes (LogosAPI, LogosAPIProvider) without directory scanning. The daemon also saves a client token via TokenManager::instance().saveToken("cli_client", token) so CLI clients can authenticate.

Build integration

The core_service_dispatch.cpp file provides a manual callMethod() dispatch table and getMethods() metadata for CoreServiceImpl. Unlike dynamically loaded modules that use logos-cpp-generator, core_service uses a hand-written dispatch because it is statically linked into the daemon binary.

The dispatch wraps argument coercion in a try/catch: a malformed RPC (e.g. a number where a string arg is expected, which makes args[i].get<std::string>() throw nlohmann::json::type_error) is converted into a structured {status:"error", code:"INVALID_ARGS", message:...} response instead of an uncaught exception that would propagate through the Qt event loop and terminate the whole daemon. This keeps one authenticated client from crashing the daemon with a single bad argument.

// core_service_dispatch.cpp — maps method names to CoreServiceImpl methods
QVariant CoreServiceImpl::callMethod(const QString& method, const QVariantList& args) {
    if (method == "loadModule") return loadModule(args.value(0).toString());
    if (method == "shutdown") return QVariant::fromValue(shutdown());
    // ... etc
}

Components

main.cpp (entry point)

Files: src/main.cpp

Purpose: Detect execution mode and dispatch to the appropriate path.

API:

Function Description
detectMode(argc, argv) -> Mode Returns Daemon or Client
main(argc, argv) -> int Dispatch to Daemon::start or a Client command

Daemon

Files: src/daemon/daemon.cpp/h

Purpose: Manage the daemon lifecycle: start liblogos, register core_service, write the daemon state file, emit the local-default client config, handle signals for clean shutdown.

API:

Method Description
Daemon::start(modulesDirs) -> int Init liblogos, register core_service, write daemon/state.json + emit client/config.json and client/auto.json, run event loop
Daemon::setupSignalHandlers() Handle SIGINT/SIGTERM for clean shutdown

DaemonConfigFile + DaemonRuntimeStateFile

Files: src/daemon/daemon_state.cpp/h

Purpose: Manage the two daemon-side config-tree files. DaemonConfigFile reads/writes <configDir>/daemon/config.json (operator preferences, written only when --persist-config is passed). DaemonRuntimeStateFile writes <configDir>/daemon/state.json on every successful boot and removes it at shutdown. Both files are daemon-owned; the client never reads config.json, and only consults state.json for a fast same-host liveness check.

API:

Method Description
DaemonConfigFile::read() -> optional<DaemonConfig> Parse daemon/config.json. Returns nullopt if missing or schema-mismatched.
DaemonConfigFile::write(cfg) Atomic write of operator-intent values. port: 0 stays 0 (resolved values live in state.json).
DaemonRuntimeStateFile::write(state) Atomic write of resolved live state (instance_id, pid, started_at, resolved.modules with actually-bound ports). The temp file staged before the atomic rename is per-writer-unique (<path>.tmp.<pid>.<seq>) so concurrent writers can't truncate/rename the same temp and corrupt the result.
DaemonRuntimeStateFile::read() -> DaemonRuntimeState Parse state.json. fileOk is true iff the file exists with a non-empty instance_id — says nothing about liveness; pair with kill(pid, 0) for that.
DaemonRuntimeStateFile::remove() Remove state.json. Called from clean shutdown / aboutToQuit hook.

state.json format (lifecycle: created at boot, removed at shutdown):

{
  "version": 2,
  "instance_id": "a3f1c8d20b4e",
  "pid": 12345,
  "started_at": "2026-03-23T14:00:00Z",
  "config_source": "cli",
  "resolved": {
    "modules_dirs": ["/path/to/modules"],
    "persistence_path": "/var/lib/logosctl",
    "modules": {
      "core_service": {
        "transports": [
          { "protocol": "local" },
          { "protocol": "tcp",     "host": "0.0.0.0", "port": 6000, "codec": "json" },
          { "protocol": "tcp_ssl", "host": "0.0.0.0", "port": 6443,
            "codec": "cbor", "ca_file": "/etc/logosctl/ca.pem",
            "verify_peer": true }
        ]
      },
      "capability_module": {
        "transports": [
          { "protocol": "local" },
          { "protocol": "tcp", "host": "127.0.0.1", "port": 6001, "codec": "json" }
        ]
      }
    },
    "ssl": { "cert": "", "key": "", "ca": "" },
    "insecure_tcp": false
  }
}

config.json format (lifecycle: written only on --persist-config): same as state.json's resolved block + version. Reflects operator intent (port: 0 stays 0).

tokens.json format (lifecycle: independent — survives daemon restarts):

{
  "version": 2,
  "tokens": [
    { "name": "auto",  "hash": "<sha256-hex>", "issued_at": "...", "expires_at": null, "local_only": true },
    { "name": "alice", "hash": "<sha256-hex>", "issued_at": "...", "expires_at": "...", "local_only": false }
  ]
}

TokenStore

Files: src/daemon/token_store.cpp/h

Purpose: In-memory map of issued client tokens, persisted into the daemon state file. Plaintext tokens are never stored on the daemon side — only SHA-256 hashes in tokens.json["tokens"]. The raw value of each named token lives only in daemon/tokens/<name>.json at the moment of issuance, for the operator to copy off.

API:

Method Description
TokenStore() Default-constructed; paths come from Config::* (the process-global config dir). Seeds itself from tokens.json["tokens"]. Tests isolate state via LOGOSCTL_CONFIG_DIR / Config::setConfigDir.
issueToken(name, expires, localOnly, replace) -> IssueResult { status, token } Mint a new token. status is one of Ok / InvalidName / AlreadyExists / IoError; token is set only on Ok. The CLI keys exit-code distinct error categories off this status so operators don't see "name collision" for permission failures. Fails closed rather than corrupting state: a CSPRNG failure (empty raw token) returns IoError and persists nothing; an on-disk tokens.json whose schema version this build doesn't support returns IoError and is left byte-for-byte intact (instead of being rewritten at the current version, wiping operator tokens). On --replace the new raw token is staged to daemon/tokens/<name>.json.new and promoted only after tokens.json commits, so a failed write never destroys the still-valid prior raw token.
revokeToken(name) -> RevokeStatus Remove the name entry from tokens.json["tokens"] and delete daemon/tokens/<name>.json. Returns Ok / InvalidName / NotFound / IoError. Like issueToken, refuses (IoError) to rewrite an unsupported-schema-version tokens.json.
listTokens() -> vector<IssuedToken> Enumerate {name, issued_at, expires_at, local_only} — never plaintext, never the digest.
lookupByToken(token) -> optional<Entry> Daemon-side: validate an incoming token against the in-memory digest map (also enforces expires_at and local_only). Fails closed on an empty token — hashToken("") is a fixed digest, so an empty credential is rejected before consulting the store and can never match a corrupt empty-hash entry.

The on-disk digest is a SHA-256 hex string — collision-resistant by design so two distinct tokens can never validate to the same name. The only place the raw token ever lives is in daemon/tokens/<name>.json at the moment of issuance; treat that file like a private key. After the operator copies it to the client host (typically into the client's <configDir>/client/), the daemon-side raw file may be deleted — validation keeps working because the hash is what the daemon checks. The state file and per-token files are written with mode 0600.

How the client finds the daemon:

The logos-cpp-sdk uses LogosInstance::id(moduleName) to build registry URLs in the format local:logos_{moduleName}_{instanceId}. The instance ID is a 12-char UUID prefix shared by all processes in the same daemon tree (via the LOGOS_INSTANCE_ID env var). Child processes (like logos_host) inherit it automatically.

The CLI client is not a child process of the daemon — it's a separate invocation. So it cannot inherit the env var. Instead:

  1. Daemon starts → LogosInstance::id() generates a3f1c8d20b4e → sets LOGOS_INSTANCE_ID
  2. core_service registers at local:logos_core_service_a3f1c8d20b4e
  3. Daemon writes instance_id into both daemon/state.json and the auto-emitted client/config.json
  4. Client reads instance_id from client/config.json → sets LOGOS_INSTANCE_ID=a3f1c8d20b4e in its own process → now LogosInstance::id("core_service") returns the matching URL
  5. Client connects via LogosAPIClient → reaches the correct daemon

The client/config.json carries instance_id rather than a hardcoded registry URL so the client can reconstruct URLs using the same LogosInstance::id() function the SDK uses internally.

The token is generated on daemon startup, hashed into tokens.json["tokens"], and emitted raw to client/auto.json for the local-default client to pick up. Client commands read it automatically via client/config.json's token_file pointer. For remote/CI usage, the token can also be passed via LOGOSCTL_TOKEN env var.

ClientStateFile

Files: src/client/client_state.cpp/h

Purpose: Read/write <configDir>/client/config.json — the client's dial spec. This is the only daemon-tree file a client command ever opens during normal RPC (it never touches daemon/state.json, daemon/config.json, or daemon/tokens.json). The daemon auto-emits one for the local-default client at boot; remote clients hand-write it (or generate it via the --client-* flags + --persist-config).

client/config.json format (version must equal kClientStateSchemaVersion, currently 2):

{
  "version": 2,
  "token_file": "dario.json",     // filename inside <configDir>/client/ holding
                                  // the raw token ({"token":"<raw>",...}); read by
                                  // readTokenFile(), which extracts the "token" field
  "instance_id": "a3f1c8d20b4e",  // optional; required ONLY for the LocalSocket dial
                                  // path (registry name local:logos_<module>_<id>).
                                  // Omitted for remote tcp / tcp_ssl clients.
  "daemon": {                     // per-module dial spec; map key = module name
    "core_service": {             // mandatory — RpcClient::connect fails without it
      "transport": "tcp",         // "local" | "tcp" | "tcp_ssl" (strict allowlist)
      "host": "192.168.1.20",     // tcp / tcp_ssl
      "port": 8645,               // tcp / tcp_ssl (0..65535)
      "codec": "json"             // "json" (default) | "cbor"
    },
    "capability_module": {        // required for any remote client: the client's own
      "transport": "tcp",         // LogosAPIClient does a requestModule handshake
      "host": "192.168.1.20",     // against capability_module before reaching
      "port": 8646                // core_service (client.cpp wires this via
    }                             // LogosAPI::setCapabilityModuleTransport)
  }
}

For tcp_ssl, each module entry also accepts "ca": "<path>" and "verify_peer": true|false.

Parsing contract (ClientStateFile::read):

  • The per-module field is transport, not protocol. An unknown value (typo) makes transportFromJson return nullopt, which fails the whole parse (ClientState{}, fileOk=false) rather than silently dropping the entry — otherwise a missing core_service/capability_module would surface as an obscure connect error later.
  • A version other than 2 is rejected with a "relaunch the daemon to regenerate, or hand-edit" message and fileOk=false.
  • codec is validated up front when supplied via --client-codec: anything other than json or cbor is a hard error (exit 1) at flag-merge time, rather than being stored verbatim and silently coerced to JSON at dial time (which would defeat the "connect fails on codec mismatch" guarantee).
  • fileOk (the "usable for dialing" bit RpcClient::connect checks) is true iff at least one daemon entry parsed and token_file is non-empty.
  • core_service and capability_module may target different ports — they're independent daemon listeners. The --client-* CLI flags apply one transport shape to both modules, so divergent ports require hand-editing this file.

API:

Method Description
ClientStateFile::read() -> ClientState Parse client/config.json (or return the in-process override set by setOverride). fileOk=false on missing file, bad version, or invalid transport entry.
ClientStateFile::write(state) -> bool Serialize a ClientState back to client/config.json (used by the --persist-config path). Writes transport/host/port/codec (+ ca/verify_peer for tcp_ssl), and instance_id only when non-empty.
ClientStateFile::setOverride(opt) Inject a CLI-flag-merged ClientState that read() returns verbatim — lets --client-* flags affect a run without writing to disk.
ClientStateFile::readTokenFile(filename) -> string Read <configDir>/client/<filename> and return its "token" field. Empty string if missing/malformed. When --token-file is passed explicitly, main now validates the content with this up front: a file that exists but yields an empty token (missing/empty token field, or unparseable JSON) is a hard error (exit 1) pointing at the bad file, instead of being accepted and surfacing later as "No authentication token" at connect time.

Client

Files: src/client/client.cpp/h

Purpose: Connect to the daemon's core_service module via LogosAPIClient and invoke its LOGOS_METHODs.

The client is a thin wrapper around LogosAPIClient. Each method maps 1:1 to a LOGOS_METHOD on core_service:

API:

Method core_service method called
Client::connect() -> bool Read <configDir>/client/config.json, set LOGOS_INSTANCE_ID from instance_id, build LogosTransportConfig from the dial spec, load token from token_file (or LOGOSCTL_TOKEN env), create LogosAPIClient targeting "core_service", authenticate
Client::isConnected() -> bool
Client::loadModule(name) -> QVariant core_service.loadModule(name)
Client::unloadModule(name) -> QVariant core_service.unloadModule(name)
Client::reloadModule(name) -> QVariant core_service.reloadModule(name)
Client::listModules(filter) -> QJsonArray core_service.listModules(filter)
Client::getStatus() -> QJsonObject core_service.getStatus()
Client::getModuleInfo(name) -> QJsonObject core_service.getModuleInfo(name)
Client::getModuleStats() -> QJsonArray core_service.getModuleStats()
Client::callModuleMethod(module, method, args) -> QVariant core_service.callModuleMethod(module, method, args)
Client::shutdown() -> QJsonObject core_service.shutdown()
Client::watchModuleEvents(module, event, callback) core_service.watchModuleEvents(module, event) + event subscription

Implementation pattern:

QVariant Client::loadModule(const QString& name) {
    return m_apiClient->invokeRemoteMethod("core_service", "loadModule", name);
}

Output

Files: src/client/output.cpp/h

Purpose: Format output for human or JSON consumption. Detects TTY status for automatic mode selection.

API:

Method Description
Output::isTTY() -> bool Check if stdout is a terminal
Output::isJsonMode() -> bool Check if JSON output is active (flag or non-TTY)
Output::printSuccess(data) Print success result (human table or JSON)
Output::printError(code, message) Print error to stderr (human) or JSON to stdout
Output::printList(items) Print a list (table or JSON array)
Output::printEvent(event) Print a single event (formatted line or NDJSON)

Config

Files: src/config.cpp/h

Purpose: Read authentication credentials from environment variables and the client's dial-spec file.

API:

Method Description
Config::getToken() -> QString Token resolution: only LOGOSCTL_TOKEN env var. Filesystem fallback (client/<token_file>) lives in ClientStateFile::readTokenFile since it requires parsing the client config.
Config::configDir() -> QString Resolve config dir: explicit setter (--config-dir) → LOGOSCTL_CONFIG_DIR env → ~/.logosctl
Config::setConfigDir(QString) Process-wide override set from main when --config-dir is passed
Config::daemonConfigPath() / daemonStatePath() / daemonTokensPath() / daemonTokensDir() Daemon-side path helpers under <configDir>/daemon/
Config::clientConfigPath() / clientDir() / clientTokenPath(filename) Client-side path helpers under <configDir>/client/. clientTokenPath rejects any filename that isn't a plain name (contains /, \, or ..) and resolves it to an in-client/ sentinel, so an operator-influenced token_file value can't escape the dir to read an arbitrary file as a credential.

The client dial spec lives in client/config.json and is loaded via ClientStateFile::read() (not Config). See the ClientStateFile section above for the schema and parsing contract.

Token resolution order:

  1. LOGOSCTL_TOKEN environment variable
  2. <configDir>/client/<token_file> (token_file defaults to auto.json when client/config.json doesn't override it)

Config dir resolution order:

  1. --config-dir <path> CLI flag (sets process-wide override, mirrors into LOGOSCTL_CONFIG_DIR)
  2. LOGOSCTL_CONFIG_DIR environment variable
  3. ~/.logosctl (default)

Parallel daemons run side-by-side when invoked with distinct --config-dir values; client commands must target the daemon by passing the same --config-dir. Two daemons may not share a config-dir: startup reads daemon/state.json and refuses (exit 1) if its recorded pid is still alive, since both would write the same state.json and either one's clean shutdown would unlink it out from under the other. A stale state.json left by a crashed daemon (pid no longer alive) is ignored and overwritten.

Command Base Class

Files: src/client/commands/command.cpp/h

Purpose: Base class for all client subcommand implementations.

API:

Method Description
Command::execute(args) -> int Run the command, return exit code
Command::client() -> Client& Access the core_service client
Command::output() -> Output& Access the output formatter

CLI Commands

All client-path commands connect to the daemon's core_service module via LogosAPIClient and call its LOGOS_METHODs. They never call liblogos C API functions directly.

logosctl daemon

Start the daemon process. This is the only command that runs the daemon path.

logosctl daemon start [--modules-dir <path>]...
logosctl daemon [--modules-dir <path>]...

Behavior:

  1. logos_core_init(argc, argv), add module directories, logos_core_start()
  2. Register core_service in-process via logos_core_register_module()
  3. Write ~/.logosctl/daemon/state.json (listeners + hashed-token table) and emit ~/.logosctl/client/config.json + ~/.logosctl/client/auto.json for the local client
  4. logos_core_exec() (Qt event loop — blocks)
  5. On SIGINT/SIGTERM: logos_core_cleanup(), remove daemon/state.json, exit

Exit codes: 0 on clean shutdown, 1 on error.

logosctl module load

Load a module into the running daemon.

logosctl module load <name>

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.loadModule(name)
  3. Prints result and exits

Exit codes: 0 on success, 2 if no daemon, 3 if module not found or load failed.

logosctl module unload

Unload a module from the running daemon.

logosctl module unload <name>

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.unloadModule(name)

Exit codes: 0 on success, 2 if no daemon, 3 if module not found or unload failed.

logosctl module ls

List available or loaded modules.

logosctl module ls [--loaded]

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.listModules(filter) — filter is "loaded" or "all"
  3. Returns all modules with status enum (loaded | not_loaded | crashed | loading)
  4. Formats and prints result with NAME, VERSION, STATUS, UPTIME columns
  5. Crash metadata (exit_code, crashed_at, crash_reason) is included in JSON for crashed modules

Exit codes: 0 on success, 2 if no daemon.

logosctl daemon status

Show overall daemon and module health.

logosctl daemon status

Behavior:

  1. Reads <configDir>/client/config.json to learn how to dial. If missing or unparseable, prints "not running" and exits with code 1 (no point trying to connect).
  2. Otherwise tries to connect and call core_service.getStatus(). The RPC call IS the liveness check — there's no separate cheap probe, because no cheap probe is correct across every transport (local Unix socket vs remote TCP across NAT is a meaningless question for PID-based liveness).
  3. On RPC timeout / connect refused: reports "not running" with the error reason, exits with code 1.
  4. On success: displays daemon info (PID, uptime, version, instance ID) and all module statuses with summary counts.

Exit codes: 0 on success, 1 if daemon not running (uses 1 not 2 because the status command itself succeeded — it's reporting the state, not failing to connect).

logosctl module reload

Unload and re-load a module.

logosctl module reload <name>

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.reloadModule(name) — core_service handles the unload/load logic internally, including fallback to plain load if module isn't currently loaded
  3. Returns result with previous_status field

Exit codes: 0 on success, 2 if no daemon, 3 if module not found or reload failed.

logosctl module show

Show detailed information about a specific module.

logosctl module show <name>

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.getModuleInfo(name)
  3. For loaded modules: displays name, version, status, PID, uptime, dependencies, and available methods — each method shows its signature and, when documented, a description sourced from the method's header doc comment (carried in the module's getPluginMethods introspection)
  4. For crashed modules: displays name, version, status, exit code, crash signal, crashed_at, restart count, last log line, PID before crash
  5. For not-loaded modules: displays name, version, status, dependencies

Exit codes: 0 on success, 2 if no daemon, 3 if module not found.

logosctl call

Call a method on a loaded module.

logosctl call <module> <method> [args...]

Alternative syntax:

logosctl module <name> method <method> [args...]

Behavior:

  1. Connects to daemon via Client
  2. Resolves @file arguments to file contents
  3. Type-coerces arguments: numeric strings → int/double, "true"/"false" → bool, rest → string
  4. Calls core_service.callModuleMethod(module, method, args) — core_service proxies the call to the target module via LogosAPIClient
  5. In human mode: prints scalar results as plain values, structured results as indented JSON, null produces no output. In JSON mode: prints the full result envelope.

Exit codes: 0 on success, 2 if no daemon, 3 if module not loaded, 4 if method not found or call failed.

logosctl watch

Watch events from a loaded module.

logosctl watch <module> [--event <name>]

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.watchModuleEvents(module, event) — core_service registers an event listener on the target module and forwards events through its own event system
  3. Client subscribes to core_service events via LogosAPIClient::onEvent()
  4. On each event: prints formatted line (human) or NDJSON line (JSON mode)
  5. Runs until SIGINT/SIGTERM

Exit codes: 0 on clean shutdown, 2 if no daemon, 3 if module not loaded.

logosctl stats

Show resource usage for loaded modules.

logosctl stats

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.getModuleStats()
  3. Formats as table (human) or JSON array

Exit codes: 0 on success, 2 if no daemon.

logosctl daemon stop

Stop the running daemon via RPC.

logosctl daemon stop

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.shutdown()
  3. core_service schedules QCoreApplication::quit() after 200ms delay
  4. If the RPC response arrives: prints success and exits
  5. If the daemon exits before the response (RPC_FAILED): treats it as success — the daemon is already gone

Exit codes: 0 on success (including when daemon exits before response), 2 if no daemon.

logosctl info

Alias for module-info. Delegates to module-info command.

logosctl module show <module>

Behavior: Same as module-info <module> — see above.

Exit codes: 0 on success, 2 if no daemon, 3 if module not found.


Call Chain: CLI → core_service → liblogos

Client commands never call liblogos functions directly. The full call chain is:

CLI client                    core_service (daemon-side)              liblogos C API
─────────                     ─────────────────────────               ──────────────
logosctl module load waku
  → Client::loadModule("waku")
    → LogosAPIClient::invokeRemoteMethod(
        "core_service", "loadModule", "waku")
      ───── IPC (Qt Remote Objects) ─────→
                                          CoreServiceImpl::loadModule("waku")
                                            → logos_core_load_module("waku", true)
                                            → build result JSON
      ←──── IPC (return value) ──────────
    → Output::printSuccess(result)
    → exit(0)

Daemon path — liblogos usage

Only the daemon path calls liblogos C API functions directly:

Daemon operation liblogos functions
Start core logos_core_init, logos_core_add_modules_dir, logos_core_start
Register core_service LogosAPI, LogosAPIProvider::registerObject (C++ SDK)
Run event loop logos_core_exec
Shutdown logos_core_cleanup

Client path — core_service method mapping

Client commands call core_service LOGOS_METHODs, which delegate to liblogos internally:

CLI command core_service method liblogos function called internally
load-module loadModule(name) logos_core_load_module(name, true)
unload-module unloadModule(name) logos_core_unload_module(name, false)
reload-module reloadModule(name) logos_core_unload_module(name, false) + logos_core_load_module(name, true)
list-modules listModules(filter) logos_core_get_known_modules, logos_core_get_loaded_modules
status getStatus() reads daemon state + listModules
module-info getModuleInfo(name) plugin metadata + methods introspection
call callModuleMethod(module, method, args) LogosAPIClient::invokeRemoteMethod (proxied to target module)
watch watchModuleEvents(module, event) LogosAPIClient::onEvent (forwarded)
stats getModuleStats() logos_core_get_module_stats
stop shutdown() QTimer::singleShot(200, ..., &QCoreApplication::quit)
info alias for module-info

Build

Nix

nix build

# The logosctl binary is at:
./result/bin/logosctl

# Run daemon
./result/bin/logosctl daemon start -m /path/to/modules

Examples

Basic Usage

# Start the daemon with module directories
logosctl daemon start --detach &

# Check daemon health
logosctl daemon status

# Load modules
logosctl module load waku
logosctl module load chat

# List loaded modules (with status and uptime)
logosctl module ls --loaded

# Get detailed module info
logosctl module show chat

# Call a method
logosctl call chat send_message "hello world"

# Reload a crashed module
logosctl module reload chat

# Watch events
logosctl watch chat --event chat-message

# Get stats
logosctl stats

# Stop daemon
logosctl daemon stop

Agent / Script Usage

# Start daemon
logosctl daemon start --detach &
sleep 2

# Preflight: verify daemon is running
logosctl daemon status --json | jq -e '.daemon.status == "running"' > /dev/null

# Check what's available and their state
logosctl module ls --json
# [
#   {"name":"waku","version":"0.1.0","status":"not_loaded"},
#   {"name":"chat","version":"0.2.0","status":"not_loaded"}
# ]

# Load modules (JSON output for parsing)
logosctl module load waku --json
# {"status":"ok","module":"waku","version":"0.1.0","dependencies_loaded":["store"]}

logosctl module load chat --json
# {"status":"ok","module":"chat","version":"0.2.0","dependencies_loaded":[]}

# Discover methods before calling
logosctl module show chat --json | jq '.methods[].name'
# "send_message"
# "get_history"
# "get_status"

# Call method and parse result
RESULT=$(logosctl call chat send_message "hello" --json)
echo "$RESULT" | jq -r '.result'

# Handle crashed modules
MODULE_STATUS=$(logosctl daemon status --json | jq -r '.modules[] | select(.name=="chat") | .status')
if [ "$MODULE_STATUS" = "crashed" ]; then
  logosctl module show chat --json | jq '{exit_code, crash_signal, restart_count}'
  logosctl module reload chat --json
fi

# Stream events to log file
logosctl watch chat --event chat-message --json >> events.log &
WATCH_PID=$!

# Check overall health before cleanup
logosctl daemon status --json | jq '.modules_summary'
# {"loaded": 3, "crashed": 0, "not_loaded": 0}

# Cleanup
kill $WATCH_PID
logosctl daemon stop

Using Environment Variables for Auth

# Set token via environment
export LOGOSCTL_TOKEN=xyz123

# Or inline per-command
LOGOSCTL_TOKEN=xyz123 logosctl module load waku

# Or via the client/ tree (point client/config.json's token_file at a JSON
# file the daemon emitted — useful for remote clients). The file is a
# {"version":1,"name":"alice","token":"<raw>","issued_at":"<iso>"}
# object that `issue-token --name alice` writes to
# <daemon-host>/.logosctl/daemon/tokens/alice.json. Copy it across
# (scp / ansible / cloud-secret-fetch) and reference it from
# client/config.json's token_file:
mkdir -p ~/.logosctl/client
scp daemon-host:~/.logosctl/daemon/tokens/alice.json ~/.logosctl/client/
# then ensure ~/.logosctl/client/config.json's token_file = "alice.json"
logosctl module load waku

Piping and Composition

# Filter loaded modules
logosctl module ls --json | jq '[.[] | select(.status == "loaded")]'

# Find crashed modules
logosctl module ls --json | jq '[.[] | select(.status == "crashed")]'

# Watch events and filter
logosctl watch chat --event chat-message --json | jq 'select(.data.from == "alice")'

# Monitor module health with status dashboard
watch -n 5 'logosctl daemon status --json | jq "{daemon: .daemon.status, modules: .modules_summary}"'

# Monitor resource usage
watch -n 5 'logosctl stats --json | jq ".[] | {name, cpu_percent, memory_mb}"'

# Auto-reload crashed modules
logosctl module ls --json | jq -r '.[] | select(.status == "crashed") | .name' | while read mod; do
  logosctl module reload "$mod" --json
done

Tests

Test File Coverage
test_commands.cpp All subcommand implementations via mock client: load/unload/reload module, list-modules, status, module-info, call, stats, watch, stop. Tests both success and error paths, JSON and human output modes.
test_mode_detection.cpp Mode detection (daemon/client/help/version), known subcommands list, argument parsing.
test_output.cpp Output formatter (human/JSON), TTY detection, printSuccess/printError/printRaw.
test_daemon_state.cpp Round-trip daemon/state.json — instance_id, pid, modulesDirs, per-module transports entries (local/tcp/tcp_ssl, codec defaulting), and the tokens array (name, hash, issued_at, expires_at, local_only). fileOk is independent of the pid (it's a parse check, not liveness).
test_token_store.cpp Token issuance (including --expires and --local-only), duplicate-name rejection (unless --replace), revocation, list, persistence round-trip. Confirms tokens.json["tokens"] stores hashes only; plaintext lives in daemon/tokens/<name>.json. Fail-closed invariants: an empty token never authenticates, issueToken Ok implies a non-empty token, a failed --replace preserves the prior raw token, and issuing against an unsupported-schema-version file refuses instead of clobbering it.
test_config.cpp Token resolution order (env var → client/<token_file>); client/config.json parsing; clientTokenPath accepts plain filenames and rejects path-traversal (../, absolute, sub-dirs).
test_port_allocator.cpp Ephemeral-port allocation: bad host returns 0, an IPv6 any-address (::) allocates a port, consecutive allocations are distinct.
test_cli.cpp End-to-end CLI tests: help, version, no-args, client commands without daemon, daemon startup with --verbose; rejection of an invalid --module-transport port, an invalid --client-codec, and a --token-file that carries no usable token.

Known Issues

  1. Event forwarding — The watch command requires core_service to forward events from target modules to CLI clients. The approach is: core_service.watchModuleEvents() registers a listener on the target module via LogosAPIClient::onEvent(), then re-emits received events via LogosProviderBase::emitEvent(). The CLI client subscribes to core_service events. This creates a relay chain (target module → core_service → CLI client) which adds latency. An alternative would be having the CLI client connect directly to the target module, but that bypasses the core_service gateway pattern.

  2. Stale state file — If the daemon crashes without removing <configDir>/daemon/state.json (and the auto-emitted client/ tree), the files stay on disk. Clients no longer pre-probe PID liveness (that only works for local daemons); instead the first RPC fails with a connect error and the status command turns that into a "not running" report. The only cost of a stale file is that the first attempt after a crash wastes one RPC timeout; in practice that's fine.

  3. Crash tracking — The daemon needs to track module crash metadata (exit code, signal, timestamp, restart count, last log line) so that listModules and getModuleInfo on core_service can report it. This may require extending liblogos to expose crash info, or core_service could track it independently by monitoring QProcess signals.

  4. callModuleMethod proxy — When core_service proxies calls to target modules via LogosAPIClient, it needs the target module's auth token. The daemon's TokenManager has all tokens, but core_service must obtain them. This may require core_service to have a privileged token or to be pre-authorized for all modules.

Future Improvements

  1. Tab completion — Shell completion scripts for bash/zsh/fish.
  2. TUI mode — Interactive terminal UI with autocomplete (like Obsidian CLI).
  3. Batch mode — Execute multiple commands from a file (logosctl batch commands.txt).
  4. module-logs command — Stream or tail module process logs (logosctl module-logs chat --tail 50). Referenced by error messages but not yet specified.
  5. Extract core_service — If core_service grows, it could be extracted into a standalone plugin loaded from disk rather than statically linked. The LOGOS_PROVIDER API makes this trivial.
  6. Capability-scoped tokens — Today all tokens are admin-equivalent. Named tokens (issue-token --name …) create separate identities but each one is still fully authorised against the daemon. A scope/capability system would let e.g. a read-only token call list-modules / status but reject load-module / stop.
  7. Client-cert TLS — The tcp_ssl transport today authenticates the daemon to the client (server cert); mutual TLS + client-cert auth would be a natural extension once we have scoped tokens, and subsumes the token-file distribution problem for many deployments.