23 Commits
Author SHA1 Message Date
Dario Gabriel LipicarandClaude Opus 5 c502fcc991 fix(search): one version per row, and name the rest in package show
The AVAILABLE VERSIONS column listed every release inline, which pushed
the table to 117 columns and wrapped every row -- blockchain_module alone
carries seven versions. Search now shows the newest and `(+N)` for the
rest, which fits 99 columns.

The full list moves to `package show NAME`, on an `available:` line. That
takes a catalog lookup, and the same lookup closes a related gap: `show`
used to refuse anything not installed, which is exactly the package you
ask about after a search. It now answers from the catalog and says
`installed: no`; PACKAGE_NOT_FOUND is raised only when the name is in
neither place. `--json` for an installed package is unchanged, and skips
the extra call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:28:25 -03:00
Dario Lipicar 0c2ab43292 feat(logosctl): show catalog package versions (#106)
* feat(logosctl): show catalog package versions

* fix(logosctl): format package version list
2026-08-25 23:12:06 -03:00
Dario Gabriel LipicarandClaude Opus 5 0f0be25959 fix(client): fail at once when the daemon is gone, and stop reporting that as data
Against a session whose daemon is no longer there, `logosctl module ls` waited
22 seconds and then printed `[]` and exited 0. Not "failed slowly" -- reported
success, with an empty module list, about a daemon that did not exist. `stats`
did the same. `call`, `package`, `catalog` and `key` waited the same 20 seconds
before reporting RPC_FAILED. Only `stop` and `status` were quick, because #100
gave them a guard the other fourteen commands never got.

The mechanism is the one #100 diagnosed. A LocalSocket client "connects" to a
socket path with no listener without complaint, QtRO surfaces no transport
error for an absent peer, and the request is therefore neither answered nor
refused -- so it waits out Timeout(20000) (logos-protocol, cpp/logos_mode.h)
and a dead daemon is indistinguishable from a slow one until the deadline
fires. Connecting is not the check it looks like.

A session outlives its daemon in two shapes, and they need different evidence.

CRASHED SESSION. daemon/state.json is still on disk naming a pid that is gone.
This is #100's check, and it was copied into stop_command and status_command.
It now lives in one place -- detectStaleSession(), called from
Command::ensureConnected() -- which is the single door every RPC-opening
command goes through, so all of them inherit it instead of the two that had it
hand-written. StatusCommand still calls the helper itself, one step earlier,
because its answer to "no daemon" is a status report rather than an error.

#100's instance_id gate is preserved exactly: the guard fires only when the
state file describes the daemon THIS client dials. A remote client can have a
co-resident daemon's leftovers sitting in its own session directory, and its
dial spec carries no instance_id at all, so an empty one never matches. The
liveness syscall now runs before the client-config read, so the common path
(daemon running) does not parse client/config.yaml twice per command.

STOPPED SESSION. The tidier way to get here, and the one the pid guard cannot
see: a clean `daemon stop` REMOVES daemon/state.json, leaving client/config.yaml
and the token behind with no pid left to find dead. Every command still waited
the full 20s. RpcClient::connect() now asks the socket instead, before it
builds a LogosAPIClient (localEndpointProvablyAbsent, src/local_endpoint.h):
the dial resolves to QDir::tempPath()/logos_core_service_<instance_id>, because
the SDK asks for the bare name (LogosInstance::id) and Qt resolves a bare
QLocalSocket/QLocalServer name against the temp dir. Deriving it the same way
is what makes the answer sound rather than a guess.

A stat alone is NOT enough, which cost this patch a wrong first draft. The
socket file outlives the daemon: a hard kill leaves it, and a clean stop leaves
it for the window between the shutdown reply and QLocalServer's destructor --
which is exactly when the next command gets typed. Measured through the new
CLI sweep, stat-only vs stat-plus-connect over the same abandoned socket: 85.3s
(every command timed out) vs 0.8s. So presence settles nothing and being
REFUSED does; ECONNREFUSED is the same signal logos::isSocketDead uses to
decide a socket is safe for the daemon's boot reaper to unlink. That function
is not reused directly only because it sits behind the logos-protocol link,
which logosctl_testlib deliberately does without.

The check fails closed on everything short of proof: a socket that accepts us,
any other connect() error, a path too long for sun_path, a non-socket inode, a
tcp/tcp_ssl dial, an empty instance_id, Windows (named pipes, no inode).
Refusing a reachable daemon would be far worse than the wait being removed.

AN UNANSWERED QUERY IS NOT AN EMPTY ONE. The exit-0 half is a separate defect
and survives independently of the timing: listModules() and getModuleStats()
answered a failed RPC with LogosList::array(), the only two calls in the client
that reported failure as data. Both now return optional<LogosList>, and the
commands report DAEMON_UNREACHABLE with exit 2. `status` had the same shape by
a different route -- RpcClient::getStatus synthesises a not_running report and
marks it `rpc_error`, and that report has a "daemon" key, so it reached the
success branch and exited 0 while printing "not running". It exits 1 now, as
docs/project.md always said it did.

`status` also connects directly rather than through ensureConnected(): that
helper PRINTS a NO_DAEMON envelope, and letting it do so put two JSON documents
on stdout for one command, which no `jq` invocation survives.

Nothing opts out of the guard. `watch` is the one command with a case for
waiting -- a daemon that has not started yet is a reasonable thing to watch for
-- but it does no waiting today: it connects once and gives up, so failing in
milliseconds is what it already meant to do. The four commands the issue listed
that are NOT covered (`token issue|revoke|list`, `daemon|client config`) never
call ensureConnected at all: they read and write the session's own files and
have no daemon to be absent.

TESTS.
  * CLITest.{Crashed,CleanlyStopped}Session_EveryRpcCommandFailsAtOnce and
    SocketLeftOverWithNoListener_EveryRpcCommandFailsAtOnce: all 17 commands
    against all three shapes, end-to-end, killed at 5s so exit 124 means the
    command was still waiting. Driven against the pre-fix binary via
    $LOGOSCTL_BINARY these fail with 124 on 15 of 17 commands, 80.3s.
  * CLITest.*_StatusReportsNotRunningAtOnce: exit 1, names the pid where there
    is one, and exactly one JSON document.
  * CommandTest.EveryRpcCommand_*: the 17 commands x 4 session shapes, against
    a mock, asserting on connectAttempts/rpcCalls -- a guard that fired is
    visible as the ABSENCE of contact. Three of the four shapes are the
    controls: live pid, foreign instance_id, and no state file at all must
    still dial.
  * LocalEndpointTest.*: the path derivation against QDir::tempPath(), plus a
    verdict for each shape the path can be in -- missing, socket with no
    listener, LIVE listener, and a regular file wearing the name.
  * CommandTest.{ListModules,Stats}_{UnansweredRpc,AnsweredWithNothing}_* and
    Status_{UnansweredRpc,LiveDaemon}_*: both sides of the empty-vs-unanswered
    line. CommandTest had no Status_ coverage at all, which is how exit 0
    survived.

Before/after over the shipped binaries, same stale session, macOS:
  module ls   exit 0 after 22s printing []   ->  exit 2 in <1s, names the pid
  stats       exit 0 after 20s printing []   ->  exit 2 in <1s
  status      exit 0 after 20s               ->  exit 1 in <1s
  call/package/catalog/key  20s, RPC_FAILED  ->  exit 2 in <1s
and against a cleanly stopped session, where nothing was fast before, all of
the above are now under a second too.

Live-daemon behaviour is unchanged and checked: 249 unit + 30 CLI + 25
integration tests pass for logosctl and 20 CLI + 24 integration for logoscore
via `nix build .#checks.<sys>.tests-logosctl` / `-logoscore`. The 25
integration tests drive real daemons through logosctl, so a wrong socket path
would fail them loudly rather than silently refusing live sessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:33:56 -03:00
Dario Gabriel LipicarandClaude Opus 5 162dbc9fff fix(daemon stop): stop losing the shutdown reply, and stop calling that a failure
`logosctl daemon stop` printed {"code":"RPC_FAILED","message":"shutdown RPC
call failed."} and exited 3 for shutdowns that had already succeeded. It cost
the "Stop the daemon" step of doctests/logosctl-daemon.test.yaml one failure
out of nine identical shutdowns in the same macOS CI job; the daemon really
had stopped, and `daemon status` two seconds later said so.

Two independent defects, one on each side of the call.

DAEMON. CoreServiceImpl::shutdown() returned {"status":"ok"} and left the
event loop from a detached std::thread that slept 200ms and called
QCoreApplication::quit(). The reply is not on the wire at that point: the
transport serialises it after the handler returns and hands it to the socket,
which only pushes it out when the event loop services that socket's write
notifier. quit() is not a queued event -- QCoreApplication::exit() interrupts
the dispatcher directly -- so if the main thread was descheduled for longer
than the sleep, the loop came back, exited, and the buffered reply died with
the process. QtRO surfaces no transport error for this; the client just waited
out its 20s deadline and saw nothing.

The quit now runs on the main thread, from a timer, and drains the event loop
before ending it. The 200ms is now a courtesy margin rather than the
correctness mechanism, and $LOGOSCTL_SHUTDOWN_GRACE_MS makes it settable --
including to 0, which the new regression test uses because it is the setting
that used to lose the reply outright.

QtRO offers nothing better: QRemoteObjectHostBase has no per-reply
write-completion signal and no client-disconnect signal, so "quit when the
response has actually been flushed" is not reachable without forking Qt, and
the daemon also serves plain TCP/TLS through a different transport.

CLIENT. RpcClient::shutdown() reported RPC_FAILED whenever the reply was not
an object -- including when there was no reply. But a missing reply is the
expected outcome of asking a process to die, and both docs said so already:
docs/spec.md promised "the client treats the connection loss as a successful
shutdown" and docs/project.md promised exit 0 for it. Neither was implemented.

It now answers the question the reply was standing in for, from evidence: the
pid recorded in daemon/state.json (snapshotted before the call, since a clean
shutdown deletes that file) is watched for up to 15s, or for a remote daemon
the endpoint is re-probed. Gone means success, with `confirmed_by` naming the
evidence; still running means a real error, with a message that says which.
Blindly treating silence as success would have been the more dangerous
mistake -- a wedged daemon is also silent -- so it is not what this does.

That inference is only sound about a pid that was alive to begin with, so
`stop` now refuses a stale session up front the way `daemon status` already
does: a state.json naming this client's instance and a dead pid means there is
no daemon to stop (NO_DAEMON, exit 2). Without it, a session left behind by
last week's daemon would "connect" to nothing, time out, observe that the pid
is gone, and call that a successful shutdown.

TESTS.
  * ShutdownReplyTest.StopSucceedsWithNoGracePeriod (integration): 60
    start/stop cycles at LOGOSCTL_SHUTDOWN_GRACE_MS=0, asserting the command
    succeeds, the daemon is actually gone, and the reply arrived rather than
    being reconstructed from the process exiting. Measured through this
    fixture on macOS: 6 losses in 100 cycles before the daemon fix, 0 in 120
    after.
  * CommandTest.Stop_StaleSession_* : the stale-session guard, its live-pid
    control, and the remote-client case it must not block. CommandTest now
    isolates HOME and the config dir, so the suite no longer reads whichever
    ~/.logosctl the developer happens to have.
  * ProcessUtil.WaitForProcessExit* : the primitive the confirmation rests on.

A/B over the shipped binaries, 30 stop cycles per arm at zero grace, macOS:
pre-fix 8 failures; daemon fix only 0 (no reply lost); client fix only 0
(20 replies lost, every command still correct); both 0. At the default 200ms
grace both arms are clean, which is why this presented as a rare CI flake.

Independent of PR #99: that PR does not touch either function, and the two
diffs do not overlap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:08:04 -03:00
Dario Gabriel LipicarandClaude Opus 5 b3f1a40310 fix(core_service): fold EVERY provider rejection code, not just dispatch_failed
`dispatchRejection` matched the single literal "dispatch_failed", so the
refusal `logosctl call` runs into most often went straight through it. Providers
answer a wrong argument COUNT with "invalid_args" — logos-cpp-sdk's cdylib
dispatch and logos-rust-sdk's args::invalid_args both do, and always have — and
no detector anywhere matched it. Measured on this very command:

  logosctl call test_basic_module isPositive     (missing required argument)
  -> exit 0, status:"ok",
     result {"code":"invalid_args","message":"expected 1 arguments, got 0",
             "origin":"test_basic_module"}

An arity error reported as a successful call that returned a map. It now reports
METHOD_FAILED with error.code "invalid_args", and `logosctl call` exits 4
instead of 0 (call_command.cpp maps any status:"error" that is not a
MODULE_NOT_* to 4).

The parent commit made the envelope read the error CHANNEL instead of judging by
the value; this makes the in-band half of the same split as complete. Both are
one question — "did this call succeed?" — and answering it from one of the two
places was the whole defect.

`code` is now matched against a CLOSED SET, in one named array so it cannot
drift from the rest of the function: dispatch_failed, invalid_args,
unknown_method. Nothing emits "unknown_method" yet and it is listed anyway,
because a detector can be widened compatibly on its own while a new provider
code cannot — one shipped against narrow detectors would arrive as data.

Closed, not open. A method may legitimately return a {code, message, origin}
map of its own, so matching the SHAPE would turn its data into an error. The
three guards (exactly three keys, all present, all strings) are untouched, and
the tests now pin that as behaviour: eight unrecognised codes including
"DISPATCH_FAILED", "dispatch_failed " and "invalid_argument", 2- and 4-key
objects, and a non-string in each of the three slots all stay DATA — plus an
end-to-end case where an unrecognised three-string map comes back as "result".

ONE THING LEFT DELIBERATELY UNDONE, recorded at the declaration: when a provider
eventually emits "unknown_method", it will fold to METHOD_FAILED here rather
than to the METHOD_NOT_FOUND envelope callEnvelope already builds from
introspection. Choosing between those two belongs with the provider-contract
change, not with widening a detector, and any routing written now would be
untested against a real provider.

Verified: 13/13 test_call_envelope, and a negative control — the two new
positive tests FAIL against the pre-change detector and pass against this one,
while the narrowness tests pass against both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:07:46 -03:00
Dario Gabriel LipicarandClaude Opus 5 ed19258375 fix(core_service): report METHOD_FAILED from the error channel, not a null value
callModuleMethod judged failure with `ret.is_null()` because it called the
one invokeRemoteMethod overload that has no CallError* parameter
(logos_api_client.h:352-356, whose body forwards to the QVariant overload
with the error channel dropped). A method that legitimately returns null was
therefore indistinguishable from a call that failed — and that single line
was the entire empirical basis for the qt-generator's refusal to allow an
optional return.

Switching to the CallError-carrying overload (logos_api_client.h:98) is not
sufficient on its own: an unknown method name is deliberately NOT reportable
on the wire (logos_protocol.h:274-279 says so outright, and the cdylib
dispatch ends `return nullptr;  // unknown method`), so a naive !err.ok()
would have turned every typo into a silent success. The decision is now:

  !err.ok()                            -> METHOD_FAILED + {code,message,origin}
  result is a dispatch_failed envelope -> METHOD_FAILED (the provider refused)
  null AND method provably not exposed -> METHOD_NOT_FOUND + available_methods
  otherwise                            -> ok, null included

METHOD_NOT_FOUND is not invented — docs/spec.md:918 specified that envelope,
with available_methods, all along; core_service simply never produced it. It
costs one extra round-trip only on a null return.

The logic lives in a new pure unit, core_service/call_envelope.{h,cpp}, with
no Qt and no logos-protocol, which is what makes it unit-testable at all. The
value path is byte-identical: the same nlohmannArgsToQVariantList /
qvariantToNlohmann the json overload used internally.

Behaviour changes a reviewer must agree with: a null return is now `ok`
rather than METHOD_FAILED, and a dispatch_failed envelope returned as data is
now METHOD_FAILED rather than `ok`. No existing test encoded the old
behaviour; no exit code or ok/error verdict flipped in any fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:08:28 -03:00
Dario LipicarandClaude Opus 5 0b2afed18a chore(deps): track master for protocol, cpp-sdk and plugin-qt (#92)
* feat(access-policy): --access-policy enforce, and prove it on a real daemon

`--access-policy` already reached the runtime; what was missing was a way to
ask for deny-by-default without hand-writing JSON, and any evidence that it
works. The README actively said the opposite ("enforcement is not yet
implemented ... a no-op for now") — it has been enforced for a while.

resolveAccessPolicyArg moves out of main.cpp into daemon/access_policy_arg.
so it can be unit-tested, and gains one spelling: the literal `enforce`
expands to {"version":1,"mode":"enforce","restrictions":{}}. That is not a
second switch — `mode` is still the runtime's only switch — it is the bare
document that arms it. Checked before the file branch, so arming enforcement
can't depend on the daemon's working directory.

The integration tests are the point: same binaries, same modules, same call,
policy the only variable. test_ipc_module declares test_basic_module and
test_extlib_module; test_basic_module declares nothing.
  no flag  -> requestModule(test_basic_module, test_extlib_module) mints
  enforce  -> the same call is refused, and both names appear in the log
  enforce  -> requestModule(test_ipc_module, test_basic_module) still mints
The third is the one that matters; a change that refused everything would
pass the second on its own. The refusal is matched structurally rather than
by exact text because the two capability_module implementations in this tree
quote the names differently.

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

* feat(qt-host): link the Qt host runtime from logos-plugin-qt, not logos-qt-sdk

logoscore's daemon and its in-process core service are built on LogosAPI,
LogosAPIProvider and LogosProviderObject. Those moved out of logos-qt-sdk
into logos-plugin-qt, which publishes them as the `logos-qt-host` package
with the CMake target logos-qt-host::logos_qt_host. Point at that target.

Those three headers were the ONLY thing this repo took from logos-qt-sdk —
it emits no Qt consumer wrappers, ships no UI plugin, and never touches
logos_qt_lp_bridge.h or logos_ui_plugin_context.h — so the logos-qt-sdk
input is dropped outright rather than kept alongside. LOGOS_QT_SDK_ROOT
becomes LOGOS_QT_HOST_ROOT in all three derivations (build, tests,
buildPortable), and `--version` now reports the logos-plugin-qt commit.

Both new failure modes are hard errors, never silent skips: an unset
LOGOS_QT_HOST_ROOT is a FATAL_ERROR before find_package runs, and a
find_package that somehow does not define the imported target is a
FATAL_ERROR too.

logos-qt-host needs TokenManager::forIdentity/isolateIdentity, which
logos-protocol only grew on its per-client-token-store commit, so the
lock moves there. logos-plugin-qt is rev-pinned for now because
nix/qt-host.nix does not exist on its default branch yet.

Verified on aarch64-darwin: `nix build .#checks.aarch64-darwin.tests`
passes 21/21 with the committed lock and no overrides (same 21 as the
pre-change baseline), .#cli and .#cli-bundle-dir build, and the set of
LogosAPI/LogosAPIProvider/LogosProviderObject/qtArgDecode symbols in the
logoscore binary is identical to the pre-change build.

* chore(deps): re-pin the SDK stack onto the pushed b4 revs

Rebased onto master, so the inputs have to name the revs the rest of the b4
stack was actually pushed at rather than each input's default branch:

  logos-cpp-sdk           a04b2788  b3 codegen tip; a strict descendant of
                                    cpp-sdk master, so forward-only
  logos-protocol          c8bab12   per-client token store — logos-qt-host
                                    calls TokenManager::forIdentity, which
                                    exists nowhere else
  logos-plugin-qt         cc24fa1   was 8ccb1fc. The superset branch that
                                    logos-liblogos and logos-module-builder
                                    also pin, so exactly ONE logos-qt-host
                                    is in the closure — this CLI links it
                                    directly AND through liblogos_core
  logos-liblogos          f2a15ef   the liblogos built on that same qt-host
  logos-capability-module 0cb33fb   master, pinned explicitly — see below

All five are rev-pinned in the URL rather than left to the lock: every one is
a branch commit, so an unpinned url lets `nix flake update` silently relock
onto a default branch that does not build here.

capability_module deliberately does NOT move to the universal port (07dba1f).
That port declares metadata.json#host_services and fails closed until a host
calls logos_module_grant_host_services — and nothing in this stack calls it
yet (neither logos-liblogos nor logos-plugin-qt contains a single call site).
Built against it, the daemon's capability gate refuses EVERY requestModule
with "not granted the token_registry host service", so no module can call
another; the new access-policy integration test caught exactly that. 0cb33fb
is what logos-liblogos and logos-standalone-app lock too.

Verified on aarch64-darwin with the committed lock and no overrides:
  .#checks.aarch64-darwin.tests-logosctl   191 + 25 + 21 tests, all PASSED
  .#checks.aarch64-darwin.tests-logoscore   20 + 24 tests, all PASSED
  .#checks.aarch64-darwin.tests             built (exit 0)
  .#packages.aarch64-darwin.{cli,ctl}       built (exit 0)

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

* chore(deps): rev-pin logos-test-modules at the b4 qt-host tip

The daemon-backed integration checks load these plugins into the daemon
this repo builds, so the two share one host runtime in one process image
-- the same constraint that already rev-pins logos-liblogos. a639b934
links the test modules against logos-qt-host rather than logos-qt-sdk and
carries the matching B4 stack pins; the previous lock sat on master
(f8077fab), which predates that repoint.

The URL had to change, not just the lock. The input was an UNPINNED url,
so it resolved to the default branch -- and f8077fab IS master's tip.
`nix flake update logos-test-modules` was therefore a silent no-op that
would leave the ten b4 commits behind while reporting success.

f8077fab is a strict ancestor of a639b934 (verified on a non-shallow
clone), so this is forward-only, not a lineage switch.

Two behaviour changes ride along and were checked against this repo's
assertions rather than assumed safe:
  * test_basic_module and test_extlib_module migrate to
    interface "universal". Neither declares metadata.json#host_services,
    so the fail-closed gate that keeps logos-capability-module pinned off
    its universal port does not apply here.
  * stringLength now answers in CHARACTERS, not bytes. Every assertion
    here is ASCII ("abcdef" -> 6), so the two agree.

The access-policy fixture still has its pair: test_ipc_module declares
[test_basic_module, test_extlib_module] and test_basic_module declares
none, so basic -> extlib stays undeclared.

Checks built by name, all exit 0: tests-logosctl, tests-logoscore,
tests. 281 tests, 0 failures, 0 skips.

* test: use test_ipc_new_api_module as the transitive-dependency fixture

These integration tests pick a module that DECLARES the other two, so one
load-module has to pull all three, and then request a token across that edge.
test_ipc_module was that fixture; it is being retired as a duplicate. Its
successor declares exactly the same dependency pair, so the fixture role
transfers unchanged.

Worth doing in the same breath as the retirement rather than after: these call
GTEST_SKIP() when the module is missing, so deleting the module out from under
them would not have turned anything red — the dependency-resolution and
token-request coverage would simply have stopped running.

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

* fix(windows): refuse an unknown target instead of silently skipping it

`logos_use_shared_runtime_from_dll` empties the static archive of each named
IMPORTED target so the symbol resolves to liblogos_core.dll's exported copy
instead. It skipped any name that was not a target, which makes a typo or a
moved target silent — and the failure it hides is the duplicate-statics class:
the image keeps its own static copy of the shared runtime alongside the DLL's,
and PE has no interposition to collapse the two.

That hazard was already WRITTEN DOWN at basecamp's call site ("naming the old
target here would be a silent no-op … Windows would regress to the 29
'rejecting unauthorized call' lines this shim exists to prevent") — documented,
but not enforced. This enforces it.

Taken from feat/sdk-codegen-phase-a, which hardened its logoscore-cli copy and
never fixed basecamp's; feat/sdk-codegen-b3 has neither. It is the one place
where reconciling onto b3 would otherwise lose work, so both copies get it.

Behaviour is unchanged for every current caller: the function early-returns off
Windows, and both call sites pass the same two targets
(logos-qt-host::logos_qt_host, logos-protocol::logos_protocol) that phase-a's
hardened copy already accepts. x86_64-windows still evaluates (386 packages).

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

* ci: use logos-co/setup-nix-cache-action for Nix setup and caching

Replaces the per-repo installer + cachix pair with the shared action, which
installs Nix with the Logos Attic cache (cache.nix.logos.co) preconfigured and
publishes what the job builds — master to the public cache, every other ref to
ci.

Each converted job also gains

    environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}

because ATTIC_TOKEN_PUBLIC only exists inside that environment. Without it the
secret resolves empty on master and publishing is silently skipped — the job
still passes, so the omission would not show up as a failure.

The action installs Nix itself on every runner, macOS included. That is a
deliberate reversal of the workaround these files carried: the comments here
said cachix/install-nix-action collides with the runner's pre-existing _nixbld
users (eDSRecordAlreadyExists), so DeterminateSystems' installer was used
instead. It no longer reproduces — logos-delivery-module has already been
converted the plain way and its `build-and-test (macos-latest)` leg passes.
Keeping the workaround would have meant a second installer plus a duplicated
substituter/key block in ten files, guarding against something two green runs
say does not happen. If it ever recurs it fails loudly at install, which is
recoverable; the silent-skip above is the failure mode worth engineering
against.

One property is deliberately NOT carried over: the old cachix step ran with
`continue-on-error: true` so a failed cache push could not fail a job whose
tests passed. The action exposes no equivalent, and adding one here would also
swallow genuine setup failures now that the same step installs Nix rather than
only publishing at the end.

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

* docs: drop references to removed generator flags and interfaces

README and docs described module authoring in terms of LogosProviderBase,
LOGOS_METHOD and --provider-header, none of which exist. Updated to the
universal model, keeping the retired shapes named as history.

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

* chore(deps): track master for protocol, cpp-sdk and plugin-qt

logos-protocol#59, logos-cpp-sdk#138 and logos-plugin-qt#19 merged, so the three
rev pins bridging to them are retired, each with its rationale rewritten to name
the PR that closed the gap.

Left pinned: logos-liblogos, logos-capability-module and logos-test-modules —
their branches are still in flight and no merged upstream was confirmed.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:47:31 -03:00
Dario LipicarandClaude Opus 5 b31bc8f89f feat(logosctl): install a local .lgx by path, and refuse the mixed request (#91)
`package install --file X.lgx` and `--dir D` already installed a package
off disk. A bare path did not: `install ./mod.lgx` treated the path as a
catalog name, fell through to the resolver, and came back with

    Cannot resolve './mod.lgx': no candidate matches './mod.lgx'

which reads like the package was rejected rather than never looked for --
and is the single most likely reason to conclude the feature is missing.

Read any argument ending in `.lgx` as a path, the way `package show` has
read it all along. A catalog name cannot carry that suffix, so the other
reading was never useful. `install`/`upgrade` only: `remove` names an
installed package, so a path there stays a name and still reports "is not
installed".

Making that safe surfaced three silent failures in the same function,
all of the same shape -- accept the argument, then quietly do something
other than what it asked:

* `install foo ./bar.lgx` installed the file and dropped `foo`. The
  daemon's plan is either/or -- local files bypass the catalog entirely
  (package_ops.cpp) -- so a mixed request did half the job and reported
  success. Positional paths make that far easier to type by accident, so
  it is now refused rather than half-honoured.

* `remove --file x.lgx` parsed the flag, handed it to a daemon branch
  that reads `names` and ignores `localFiles`, and reported "Nothing to
  do -- already up to date" having removed nothing.

* `--file a.lgx --dir empty/` tested emptiness against the combined list,
  so a `--dir` that contributed nothing passed unreported. The count is
  now scoped to what the directory itself added.

A missing path is also reported as a missing file now, instead of
reaching the resolver and coming back as a package-not-found.

Six unit tests cover the parsing, each asserting that a refused request
never reaches the daemon. logosctl-local-install.test.yaml covers the
whole loop end to end -- inspect, dry-run, install by path, load, call,
`--dir` reinstall, both refusals, remove -- with no catalog and no
network. That hermetic half is the gap next to logosctl-packages, which
drives the live catalog and cannot run offline. The workflow globs
doctests/*.test.yaml, so it is picked up with no CI change; sections are
marked linux/macos because a nix-built .lgx carries only the build host's
variant.

README: installing from the catalog needs the portable bundle, but a
locally built .lgx carries a `-dev` variant and needs the dev build. The
existing wording claimed the portable bundle for package commands
generally, which is the wrong half of the contract for this path.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:54:58 -03:00
Dario LipicarandClaude Opus 5 2ddbb36f23 fix(logosctl): don't let one bundled-module failure disarm signature_policy (#90)
bootstrapPackageModules loaded "package_manager" and "package_downloader"
in a loop that `return`ed on the first failure -- before every
set*Directory / setSignaturePolicy call that followed it. Three defects
came out of that one early return:

* It left the daemon half-configured on any platform, not just Windows.
  A package_downloader that failed to load for any reason on Linux or
  macOS took the whole configuration block with it, while
  package_manager stayed up.

* The consequence was not the one originally recorded in flake.nix
  ("installs would land wherever its unset defaults point"). Every
  directory in package-manager-lib fails closed when unset --
  installPlugin refuses with "User modules directory is not set". What
  does NOT fail closed is the signature policy: it defaults to WARN, so
  an operator's `signature_policy: require` was read, advertised in
  state.json and by `logosctl config get`, and enforced nowhere.
  Unsigned packages, and packages signed by untrusted keys, would
  install with a printed warning.

* Because "package_manager" was first in the list, its failure meant
  "package_downloader" was never attempted, even when it would have
  loaded fine.

The stderr warning was wrong in the same way: both failures claimed
"package commands will be unavailable", which is untrue for either
module on its own.

The sequencing now lives in src/daemon/package_bootstrap.cpp behind
injected hooks -- it was not reachable from a test through
logos_core_load_module and a live socket. Each module loads
independently, package_manager is configured whenever it loaded, and
each failure names only the capability it actually costs.

Delivery is now checked. The setters return void, so a dispatched call
and one that never arrived are indistinguishable in the return value;
the CallError overload of invokeRemoteMethod tells them apart. A
configured policy that cannot be delivered unloads package_manager
rather than leave it enforcing less than the session advertises. An
undelivered directory only warns, since those fail closed on their own.

This matters more since cbd4c09: modules-pkg now ships on Windows, so
both modules are loaded on a platform where the daemon had never run
them before.

flake.nix already records both corrections (cbd4c09 reached them
independently); its remaining forward-reference to a defect "tracked
separately" is retired to point at the fix.

tests/test_package_bootstrap.cpp covers all three defects and the
warning text: 7 of its 12 cases fail against the old control flow.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:39:25 -03:00
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
Dario LipicarandClaude Opus 4.8 679a9af8fd fix: report module version in list-modules and module-info (#59) (#60)
* fix: report module version in list-modules and module-info (#59)

The version column was always empty and the JSON omitted version entirely
(`delivery_modulev` in the table = name + "v" + empty). The data layer
never populated it.

Source it generically from liblogos' new logos_core_get_modules_info(),
which returns name/path/loaded/dependencies/dependents/metadata per known
module. listModules and getModuleInfo now build from that single call, so:

- list-modules shows VERSION (table) and "version" (JSON) for loaded AND
  not_loaded modules (version comes from on-disk metadata).
- module-info reports version plus dependencies/dependents.
- load-module / reload-module responses include the version.

Output: empty versions render as "-" (modules aren't required to declare
one), and the NAME/VERSION table columns size to their content so long
names no longer collide with the version.

Tests: OutputTest cases for the table/dash/collision rendering; an
integration ReportsModuleVersion test (real daemon) covering version +
dependencies across list-modules/module-info/load-module; doctest
assertions.

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

* feat: report uptime for loaded modules (#59)

list-modules, status, and module-info now report uptime_seconds for loaded
modules, derived from the load timestamp liblogos records (now - loaded_at).
Unloaded modules report no uptime_seconds (uptime is loaded-only). The
daemon stamps loaded_at with the same wall clock core_service reads, so the
value is consistent.

Tests: ReportsModuleVersion asserts uptime_seconds is absent for an
unloaded module and present once loaded.

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

* chore: bump logos-liblogos to merged modules-info API (#159)

Re-pin logos-liblogos 87ae7ce → 819faac (master, #159) and its transitive
logos-module a3e288a → 2ec64c4 (#21), so the version/uptime/deps features
build against the merged generic modules-info API without overrides.

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

* feat: add --no-json/--human flag; doctest shows both output forms

Every client command auto-selects human output on a TTY and JSON when piped.
Add --no-json (alias --human) as the explicit inverse of --json, forcing the
human-readable form even when piped — useful for scripts/log capture and for
docs that want to show the terminal view deterministically.

The "Running Modules with the logoscore Daemon" doctest now shows both the
human table and the JSON for status, list-modules, module-info, stats, and
call, using --human/--json to render each form.

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

* docs(doctest): show human and JSON output samples for both forms

The doctest generator renders commands and prose but not captured output,
so add post_text blocks displaying both the human-readable and JSON output
for status, list-modules, module-info, stats, and call. The steps already
run both forms (--human/--json) and assert on them; this surfaces the
outputs in the generated tutorial.

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

* docs(doctest): restore unrelated generated outputs

run.sh clears outputs/ wholesale and only regenerates the spec(s) passed to
it, so regenerating just logoscore-daemon.md inadvertently dropped the
transports and concurrent-blocking tutorials. Restore them unchanged — this
PR only touches the daemon doctest.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:46:22 -03:00
Iuri Matias 08956f2ce3 several fixes and improvements 2026-06-15 10:52:45 -04:00
Dario LipicarandClaude Opus 4.8 a6dbd95f03 refactor!: remove legacy inline (-c) mode; daemon + client only (#41)
* refactor!: remove legacy inline (-c) mode; use daemon + client only

Inline mode (the flat-flag, single-process path: `logoscore -m <dir> -l
<modules> -c "module.method(args)" [--quit-on-finish]`) is legacy and its
arg/async handling regressed in the current runtime. Module method calls now
go exclusively through a daemon (`logoscore -D`) plus the `call`/`load-module`
client subcommands, which keep a persistent event loop and work reliably.

Removed:
- `-c`/`--call` and `--quit-on-finish` options
- `runInlineMode()` in main.cpp and the inline dispatch branch
- `src/inline/` (CallExecutor, command_line_parser / parseCallString / CoreArgs)
- the inline sources from CMakeLists
- the `InlineMode_*` end-to-end tests (daemon/client coverage retained; the
  DaemonMode relative-path test already covers path resolution)

Kept (now daemon-only): `-m`/`--modules-dir`, `-l`/`--load-modules`,
`--persistence-path` configure daemon startup with `-D`. Invoking these without
`-D` and without a subcommand now prints a clear error pointing at the
daemon/client workflow instead of silently running inline.

Docs (README, docs/spec.md, docs/project.md) updated to drop inline mode and
describe the daemon + client workflow.

Verified: `ws test logos-logoscore-cli` passes (builds + unit/cli/integration
tests green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: reject daemon-only flags (no -D / with a client subcommand) + tests

Address review on the inline-mode removal:

- Reject -m/-l/--persistence-path when used without -D — both with a client
  subcommand (previously silently ignored, e.g. `logoscore -m /p status`) and
  on a bare invocation. Factored into one rejectDaemonOnlyFlags() helper used
  by both paths; also fixes the mid-sentence line break in the message.
- Add CLI tests for the new error path: daemon flags with no -D/no subcommand,
  and daemon flags alongside a client subcommand both exit 1 with guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor!: daemon starts clean — remove -l/--load-modules autoload

The daemon never actually autoloaded -l/--load-modules (only the now-removed
inline path did); the flag was parsed and persisted but unused at startup. Make
that explicit: the daemon starts clean and modules are loaded via the
`load-module` client command.

- Remove the `-l/--load-modules` option and its config-merge/reject handling.
- Drop the vestigial `loadModules` field from DaemonConfig (and thus
  state.json's `resolved`), its JSON (de)serialization, and the daemon_state
  tests that asserted it.
- Update README/spec/project docs + the daemon flags table.

Verified: `ws test logos-logoscore-cli` passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:36:53 -03:00
Dario LipicarandClaude Opus 4.8 d4027d0b29 Surface module events in module-info (#38)
* Surface module events in module-info

module-info now reports a module's events alongside its methods. The
core_service gateway calls the wrapped module's getPluginEvents
introspection and threads the result into the module-info payload, and
the client prints an Events section (name + params, no return type,
plus the per-event description) after Methods.

- core_service_impl: getModuleInfo also invokeRemoteMethod(name,
  "getPluginEvents") -> info["events"].
- output: print an Events section mirroring Methods (multi-line
  descriptions preserved, indented).
- docs: README + spec event examples.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix off-by-one in description rendering; add events output test

Address review feedback (#38):

- output: the multi-line description loop used 'start <= size()', which
  printed an extra blank indented line when a description ended with a
  trailing newline. Use a strict '<' bound (applied to both the methods
  and events loops).
- test_output: new human-mode test asserts the Events section renders
  'name(params)' with no return arrow and preserves a multi-line
  description.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:17:03 -03:00
Dario LipicarandClaude Opus 4.8 4476d8a268 Show per-method description in module-info; fix method JSON keys (#37)
* show per-method description in module-info; fix method JSON keys

module-info now prints each method's description (from getPluginMethods).
Also fixes the methods loop to read returnType/parameters (the actual
getPluginMethods keys) instead of return_type/params, which previously
left return types and params blank. Docs updated to the real schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* render multi-line method descriptions in module-info

Print each line of a method's description indented, preserving the doc
comment's original line breaks. Docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test module-info human output: method schema + multi-line description

Adds a human-mode printModuleInfo test asserting the method signature is built
from returnType/parameters (the keys this PR fixes) and that a multi-line
description is rendered line-by-line. Addresses review feedback on #37.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:42:16 -03:00
Dario Gabriel Lipicar 0299faeac2 improve README for remote operation 2026-05-28 22:53:27 -03:00
Iuri Matias 3757980585 update liblogos; use new APIs
update liblogos; use new APIs

update liblogos; use new APIs

fix docs

more fixes
2026-05-19 16:53:41 -04:00
Dario Lipicar 832f72ca4e cleanup core_manager references (#26) 2026-05-15 09:08:31 -03:00
Dario Lipicar 5a1cf746e8 ensure local transport is always enabled (#25)
* ensure local transport is always enabled

* PR comments
2026-05-08 00:54:20 -03:00
Dario Lipicar 93ec7fa568 support non-local remote transports (#22)
* support non-local remote transports

* fix LogosResult

* update READMEs

* use explicit transport only for core_service

* fix tests

* fix argument parsing

* pr comments

* allow transport set configuration on any module

* pr comments

* simplify flake.nix

* split config and state files

* pr comments

* pr comments

* pr comments

* fixes

* fix flake.nix
2026-05-07 13:36:07 -03:00
Dario Lipicar 04d8374982 allow overriding user directory via launch argument (#20)
* allow overriding user directory via launch argument

* pr comments

* docs
2026-04-22 12:38:11 -03:00
Iuri Matias 523c83260c update docs 2026-03-25 11:06:41 -04:00
Iuri Matias 17d181616a initial code for daemon mode 2026-03-24 17:28:59 -04:00