feat(cli): extract Obelisk runtime into npm package

Add @obelisk-apps/cli with the existing build, search, query, and attune contract plus official skill installation.

Separate the docs-only skill artifact, bootstrap installer, release layout, cross-platform CI, and package-level regression coverage.
This commit is contained in:
tommy0103
2026-07-16 17:26:23 +08:00
parent 49158e9b9c
commit 90191e4604
64 changed files with 1112 additions and 745 deletions
@@ -7,7 +7,8 @@
> persist layer, not one per binding.
**Context.** Obelisk had two divergent full indexers — the former
`scripts/indexer.mjs` (`node:sqlite`, skill/runtime) and `app/indexer.js`
`scripts/indexer.mjs` (`node:sqlite`, the former skill-embedded runtime) and
`app/indexer.js`
(`better-sqlite3`, Electron
app) — that duplicated the same Claude and Codex JSONL parsing and had silently
diverged in write semantics (`INSERT OR REPLACE` vs `ON CONFLICT DO UPDATE`,
@@ -31,14 +32,14 @@ binding-agnostic and does not need a per-binding implementation.
incremental `index_state` bookkeeping, FTS maintenance, and the canonical
**upsert** (`ON CONFLICT(uuid) DO UPDATE`) write semantics reconciled from the
drift on 2026-07-08. The database handle is *injected*, so `node:sqlite`
(skill/CLI) and `better-sqlite3` (app) run the same code — there is no
(CLI) and `better-sqlite3` (app) run the same code — there is no
per-binding persist layer.
**Two indexing modes** share all of the above and differ only in trigger:
**daemon mode** (app/CLI watches and keeps the index fresh) and **passive pull
mode** (skill indexes on invocation when no daemon is active). They never write
concurrently — passive mode detects a fresh daemon via heartbeat markers in
`index_state` (**daemon arbitration**).
**daemon mode** (the app, and potentially a future CLI daemon, watches and keeps
the index fresh) and **passive pull mode** (a CLI command indexes on invocation
when no daemon is active). They never write concurrently — passive mode detects
a fresh daemon via heartbeat markers in `index_state` (**daemon arbitration**).
**Consequences.** Golden tests anchor on each adapter's `parse` output (feed
fixture JSONL, assert the yielded record sequence) — independent of binding and
+4 -4
View File
@@ -5,8 +5,8 @@ pin what "the contract" is so refactoring cannot silently change observable
behavior. The four verbs (`build`/`search`/`query`/`attune`) are only the entry
surface; agents actually depend on the *return shapes* of the sandbox helpers
(`search`, `overview`, `memories`, …), which are already documented in
`references/api-reference.md` and relied on by every example in
`references/query-patterns.md`. Current behavior is good and there is no reason to
`skill-doc/references/api-reference.md` and relied on by every example in
`skill-doc/references/query-patterns.md`. Current behavior is good and there is no reason to
change it during migration.
**Decision.** Freeze the contract in two tiers. **Tier 1 (hard freeze, golden
@@ -16,8 +16,8 @@ read-only enforcement, `attune` exposing only `remember`/`forget`, the set of
globals/helpers available inside `query`/`attune`). **Tier 2 (locked to
api-reference.md):** each helper's documented return shape — not frozen forever,
but never allowed to drift silently; contract tests assert the live shape matches
`references/api-reference.md`, so changing a helper forces a doc change plus a
deliberate version bump. `references/api-reference.md` is therefore promoted from
`skill-doc/references/api-reference.md`, so changing a helper forces a doc change plus a
deliberate version bump. `skill-doc/references/api-reference.md` is therefore promoted from
description to authoritative contract, and Phase 1 becomes "make it authoritative
and enforce it," not "write a new contract doc."
@@ -1,25 +1,26 @@
# Core is authored in TypeScript, shipped as precompiled ESM JavaScript
**Context.** The extracted Obelisk Core must serve two consumers — the ESM skill
runtime (`node:sqlite`) and the CommonJS Electron app (`better-sqlite3`) — while
the skill artifact must install with **zero build step** on the user's machine
(the clone-and-run, "low-friction skill" goal). Authoring in TS gives the infra
its checkable contracts, but raises how the compiled output is shipped and which
module format it targets.
**Context.** The extracted Obelisk Core must serve two consumers — the ESM CLI
runtime (`node:sqlite`) and the Electron app (`better-sqlite3`) — while the CLI
must install with **zero build step** on the user's machine. Authoring in TS
gives the infrastructure checkable contracts, but raises how compiled output is
shipped and which module format it targets. The formal agent skill is a separate
docs-only artifact and must not carry a second runtime.
**Decision.** Author all of Core in the `@obelisk/core` npm workspace
(`packages/core`) in TypeScript and compile it ahead-of-time to
**ESM JavaScript plus `.d.ts`**. The skill/CLI runtime ships the *precompiled*
ESM JS, so installing the skill never runs a build. Rather than have Core
**ESM JavaScript plus `.d.ts`**. `@obelisk-apps/cli` ships the *precompiled*
ESM JS, so installing the CLI never runs a build. Rather than have Core
dual-publish CJS+ESM, the Electron main process migrates to ESM at Phase 5 so it
can `import` the same compiled Core. TypeScript source is the single source of
truth; the build step lives in the main repo (`build:skill`), never on the user's
machine.
truth; the package build lives in the main repo (`build:cli`), never on the
user's machine. `build:skill` copies only `skill-doc/SKILL.md`, references, and
skill metadata.
**Consequences.** A one-time ESM migration of the Electron main process (Phase 5),
in exchange for no dual-build maintenance and a single module format across skill,
CLI, and app. The shipped skill artifact contains compiled JS, not TS. The
in exchange for no dual-build maintenance and a single module format across the
CLI and app. The shipped CLI package contains compiled JS, not TS. The
renderer (Vue) is out of scope and stays JavaScript. Phase 3's TS baseline only
adds root tooling (package.json, tsconfig, ESLint); it does not touch the app.
The app imports Core source so electron-vite can bundle it, while package and
skill builds compile the same workspace source to JavaScript.
CLI builds compile the same workspace source to JavaScript.
@@ -1,22 +1,21 @@
# The skill artifact ships readable compiled JS, deliberately not bundled
# The CLI ships readable compiled JS; the skill remains docs-only
**Context.** Obelisk reads a user's entire local Claude Code and Codex history,
so auditability is the foundation of trust: before a user lets the skill loose on
their data, they must be able to read what it does. The obvious way to shrink a
clone-and-run skill artifact is to bundle/minify Core into a single `runtime.js`,
but that ships an opaque blob into `.claude/skills` / `.agents/skills`. The
"don't drag the whole repo into the user's skills dir" concern is real but
separate — it is solved by shipping *only Core*, not by bundling.
so auditability is the foundation of trust. Bundling/minifying Core into one
opaque file would make the runtime harder to inspect. Shipping executable Core
inside `.claude/skills` / `.agents/skills` would also blur the boundary between
the agent's instructions and the local data runtime.
**Decision.** The skill artifact ships **readable, non-bundled, non-minified**
**Decision.** `@obelisk-apps/cli` ships **readable, non-bundled, non-minified**
compiled JavaScript emitted straight from `tsc` (module structure and comments
preserved, ~1:1 with the TypeScript source), plus `schema.sql`, `SKILL.md`, and
`references/`. It excludes `app/`, `release/`, `renderer/`, Electron code, and
`tests/`, which is what keeps it small. Bundling into one file is deliberately
rejected: it trades auditability for marginal size, the wrong trade for a
history-reading tool. The public TS source in the main repo allows cross-checking.
preserved, ~1:1 with the TypeScript source), plus `schema.sql`. It excludes the
app, renderer, release assets, and tests. The separately published agent skill
ships only `SKILL.md`, `references/`, and metadata; every executable action in
the skill delegates to the installed `obelisk` command. Bundling into one file
is deliberately rejected because it trades auditability for marginal size.
**Consequences.** The installed skill is a few readable files rather than one
blob; a future contributor may be tempted to "optimize" by bundling — this ADR
records that the un-bundled form is intentional. Small artifact size comes from
scoping the artifact to Core, handled by `build:skill`, not from a bundler.
**Consequences.** Runtime ownership is unambiguous: npm installs the CLI, while
the skills installer installs only agent guidance. A future contributor may be
tempted to re-embed Core in the skill or bundle the CLI — this ADR records that
both are intentional boundaries. `build:cli` owns compiled code;
`build:skill` owns docs-only packaging.
+1 -1
View File
@@ -25,7 +25,7 @@ decisions within this:
injecting `better-sqlite3`. This
works because the provider→parsing import graph is node:sqlite-free (ADR-0001),
so nothing drags `node:sqlite` into the app. The `dist/` from `build:core`
(ADR-0003) remains for the skill artifact; the app does not need it.
(ADR-0003) remains for the CLI package; the app does not need it.
- **better-sqlite3 stays the app's binding**, externalized (not bundled) and
unpacked from the asar.
- **The app main + preload source is TypeScript with types at its seams**, but
@@ -7,7 +7,7 @@ threw over the primary exception and turned a skippable per-file failure into a
whole-build failure. The masked exception was not preserved, so contention
(`SQLITE_BUSY` / `SQLITE_BUSY_SNAPSHOT`) is the leading explanation rather than
a proven historical fact. It is plausible because daemon builds, manual
rebuilds, skill passive-pull indexing, heartbeat writes, and reads share one WAL
rebuilds, CLI passive-pull indexing, heartbeat writes, and reads share one WAL
database.
`busy_timeout` alone is not a correctness fix. In particular,
@@ -35,15 +35,15 @@ layers.
failures propagate. `affectedSessionIds` is updated only after the relevant
commit. Force cleanup is one atomic, retryable transaction, and finalize is
likewise retried as a complete idempotent transaction.
- A fresh `__app_heartbeat__` is policy ownership: while it is fresh, the skill
- A fresh `__app_heartbeat__` is policy ownership: while it is fresh, the CLI
opens no write connection and performs no migration, schema setup, checkpoint,
index build, or `attune`. `__app_last_successful_build__` remains an
observability/freshness marker and is not required for ownership. The skill
observability/freshness marker and is not required for ownership. The CLI
checks ownership again after acquiring the hard lease to close the TOCTOU
window. Search/query connections are read-only.
- A dedicated `.obelisk/writer.lock.sqlite` provides the cross-process safety
mutex on every platform. Acquisition is `BEGIN IMMEDIATE` with non-blocking or
bounded waiting; release is idempotent. App builds and heartbeats, skill builds
bounded waiting; release is idempotent. App builds and heartbeats, CLI builds
and attune, app schema/legacy migrations and memory mutations, and manual
rebuild all participate. Manual rebuild's main process owns the lease across
worker build, atomic target replacement, and database reopen; the worker uses
@@ -52,7 +52,7 @@ layers.
deferral retains changed paths and schedules a short retry without announcing
a successful build. Service start publishes the ownership heartbeat
immediately, then refreshes it periodically.
- Index-writer and skill read connections use an explicit 250 ms SQLite busy
- Index-writer and CLI read connections use an explicit 250 ms SQLite busy
timeout inside the larger bounded coordination budget. The long-lived app
query connection retains a 5 s timeout; heartbeat is deliberately non-blocking
(`0 ms`) so it never stalls the Electron main thread. Builds use