litcodex-ai 0.3.3 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,10 +2,10 @@
2
2
  //
3
3
  // This compiles to dist/cli.js and is imported by bin/litcodex.js. It owns the routing table for
4
4
  // every litcodex subcommand and is fully self-contained: it NEVER spawns a child process itself and
5
- // NEVER forwards to a separate package. The ONLY child process the installer ever spawns is `codex`,
6
- // and that spawn lives behind the install/* modules (dist/install/*.js), never in this file
7
- // keeping dist/cli.js free of any spawn/forwarder token. Unknown subcommands exit 1 with a
8
- // plain-text usage notice (LITCODEX_INSTALL_UNKNOWN_COMMAND).
5
+ // NEVER forwards to a separate package. Child processes live behind dedicated modules, never in this
6
+ // file: the installer spawns `codex` (dist/install/*.js), and the update notifier spawns a detached
7
+ // refresh child (dist/update-check.js). dist/cli.js itself stays free of any spawn/forwarder token.
8
+ // Unknown subcommands exit 1 with a plain-text usage notice (LITCODEX_INSTALL_UNKNOWN_COMMAND).
9
9
  //
10
10
  // Two surfaces:
11
11
  // - `dispatch` — the PURE synchronous router (help/version/unknown + a pure dry-run install plan).
@@ -21,6 +21,7 @@ import { LITCODEX_REPO_URL } from "./install/marketplace.js";
21
21
  import { buildInstallPlan } from "./install/plan.js";
22
22
  import { renderInstallPlan } from "./install/render-plan.js";
23
23
  import { renderBanner, shouldDecorate } from "./ui.js";
24
+ import { maybeNotifyAndRefresh } from "./update-check.js";
24
25
  /** Error code emitted when a subcommand is not in the routing table. */
25
26
  export const UNKNOWN_COMMAND_CODE = "LITCODEX_INSTALL_UNKNOWN_COMMAND";
26
27
  /** Router usage code (EX_USAGE) for an unknown `config` sub-subcommand. */
@@ -140,6 +141,15 @@ export async function runCli(argv) {
140
141
  const rest = argv.filter((token) => token !== "--dry-run");
141
142
  const isHelp = rest.includes("--help") || rest.includes("-h");
142
143
  const isVersion = rest.includes("--version") || rest.includes("-v");
144
+ // Non-blocking update notifier: reads a cache written by a previous run's background child.
145
+ // Gated to interactive TTY, no CI, no opt-out envs, no --json. Writes to stderr (stdout clean).
146
+ maybeNotifyAndRefresh({
147
+ current: manifest.version,
148
+ isTty: Boolean(process.stdout.isTTY),
149
+ env: process.env,
150
+ stderr: process.stderr,
151
+ json: argv.includes("--json"),
152
+ });
143
153
  if (!isHelp && !isVersion) {
144
154
  const head = rest[0];
145
155
  if (head === "config" && rest[1] === "migrate") {
@@ -5,9 +5,10 @@
5
5
  // no-op regardless of Codex's own re-add exit codes. `config-update` is in-process: it calls
6
6
  // `migrateCodexConfig({ env, cwd: codexHome, mode: "install" })` (A3 C4) and maps every
7
7
  // `CodexConfigMigrationError` → `LITCODEX_INSTALL_CONFIG_WRITE_FAILED` (exit 3). `hooks-register`
8
- // is an in-process verification probe (A4) over the bundled metadata never a config write.
9
- // `agents-install` copies bundled litwork agent .toml files into <codexHome>/agents/ with
10
- // backup-safe + idempotent semantics.
8
+ // is a best-effort DEV-ONLY drift guard over the un-bundled plugin metadata (the real hook is wired
9
+ // by `codex plugin add` from the marketplace), so it passes silently in a published install.
10
+ // `agents-install` copies bundled litwork agent .toml files (resolved relative to THIS package, so
11
+ // npx/global installs work regardless of cwd) into <codexHome>/agents/, backup-safe + idempotent.
11
12
  import * as nodeFs from "node:fs";
12
13
  import { createRequire } from "node:module";
13
14
  import { dirname, isAbsolute, join } from "node:path";
@@ -59,9 +60,11 @@ async function runStep(step, codexBin, deps) {
59
60
  /** Copy bundled litwork agent .toml files into <codexHome>/agents/ (backup-safe, idempotent). */
60
61
  function runAgentsInstall(step, deps) {
61
62
  const wfs = deps.writeFs ?? nodeFs;
62
- // Resolve source dir: injected override or resolve via require from repoRoot.
63
- const sourceDir = deps.agentsSourceDir ??
64
- join(dirname(createRequire(`${deps.repoRoot}/package.json`).resolve("@litcodex/lit-loop/package.json")), "agents");
63
+ // Resolve source dir: injected override, else the BUNDLED @litcodex/lit-loop. `@litcodex/lit-loop`
64
+ // is bundled inside the litcodex-ai package, so it must be resolved relative to THIS module first
65
+ // (works for npx/global installs where cwd is unrelated to the package); the repoRoot anchor is a
66
+ // dev/in-repo fallback (workspace symlink under the repo root).
67
+ const sourceDir = deps.agentsSourceDir ?? join(resolveLitLoopDir(deps.repoRoot), "agents");
65
68
  // Validate source dir has .toml files.
66
69
  if (!wfs.existsSync(sourceDir)) {
67
70
  throw new InstallError("LITCODEX_INSTALL_AGENTS_MISSING", "Bundled litwork agent roles are missing.", {
@@ -146,14 +149,25 @@ function runHooksRegister(step, deps) {
146
149
  }
147
150
  return { kind: step.kind, status: "skipped", detail: "UserPromptSubmit hook wired (manifest-driven)" };
148
151
  }
149
- /** Default hook verification: load + resolve the M14 metadata over repoRoot (dev/test only). */
152
+ /**
153
+ * Best-effort, DEV-ONLY hook drift guard. The hook payload (the `@litcodex/plugin` metadata resolver
154
+ * and `.agents/plugins/marketplace.json`) ships only in the repo/plugin tree — it is NEVER bundled
155
+ * into the published `litcodex-ai` tarball. In a real install the UserPromptSubmit hook is wired by
156
+ * `codex plugin add` from the GitHub marketplace, not by reading a local file. So this guard resolves
157
+ * the dev payload lazily and SILENTLY PASSES when it isn't present (published / npx install). Only a
158
+ * genuine command-literal drift — a real dev defect — throws.
159
+ */
150
160
  function defaultVerifyHook(repoRoot) {
151
- // Resolved lazily so the installer never hard-depends on the un-bundled @litcodex/plugin tree.
152
- // (In a published tarball this path is exercised via the injected verifyHook; here it backs the
153
- // in-repo run.) The require is wrapped so a missing resolver surfaces as a typed HOOKS_MISSING.
154
- const req = createRequireForRepo(repoRoot);
155
- const meta = req("@litcodex/plugin/dist/metadata.js");
156
- const metadata = meta.loadMarketplaceMetadata(repoRoot);
161
+ let meta;
162
+ let metadata;
163
+ try {
164
+ meta = createRequireForRepo(repoRoot)("@litcodex/plugin/dist/metadata.js");
165
+ metadata = meta.loadMarketplaceMetadata(repoRoot);
166
+ }
167
+ catch {
168
+ // No dev payload here → published/npx install. Codex wires the hook from the marketplace.
169
+ return;
170
+ }
157
171
  const handler = meta.resolveUserPromptSubmitHook(metadata);
158
172
  if (!handler.command.includes("hook user-prompt-submit") || !handler.command.includes("cli.js")) {
159
173
  throw new Error("hook command literal drift");
@@ -162,6 +176,24 @@ function defaultVerifyHook(repoRoot) {
162
176
  function createRequireForRepo(repoRoot) {
163
177
  return createRequire(`${repoRoot}/package.json`);
164
178
  }
179
+ /**
180
+ * Resolve the bundled `@litcodex/lit-loop` package directory. Tries THIS module first (so a published
181
+ * npx/global install finds the copy bundled under litcodex-ai/node_modules regardless of cwd), then
182
+ * the repoRoot anchor (the dev/in-repo workspace symlink). Throws AGENTS_MISSING when neither resolves.
183
+ */
184
+ function resolveLitLoopDir(repoRoot) {
185
+ for (const anchor of [import.meta.url, `${repoRoot}/package.json`]) {
186
+ try {
187
+ return dirname(createRequire(anchor).resolve("@litcodex/lit-loop/package.json"));
188
+ }
189
+ catch {
190
+ // Try the next anchor.
191
+ }
192
+ }
193
+ throw new InstallError("LITCODEX_INSTALL_AGENTS_MISSING", "Bundled @litcodex/lit-loop is not resolvable.", {
194
+ repoRoot,
195
+ });
196
+ }
165
197
  /** In-process config migration via the M13 engine; collapses all M13 codes → CONFIG_WRITE_FAILED. */
166
198
  async function runConfigUpdate(step, deps) {
167
199
  let res;
@@ -0,0 +1,46 @@
1
+ /** Path to the on-disk version-check cache. */
2
+ export declare const CACHE_PATH: string;
3
+ /** Re-check at most once every 24 hours. */
4
+ export declare const STALE_MS: number;
5
+ export interface UpdateCache {
6
+ checkedAt: number;
7
+ latest: string;
8
+ }
9
+ /**
10
+ * Read and parse the cached update-check result. Returns null for missing, corrupt, or
11
+ * unreadable files. NEVER throws.
12
+ */
13
+ export declare function readUpdateCache(path?: string): UpdateCache | null;
14
+ /**
15
+ * True when `latest` is strictly newer than `current` (major.minor.patch numeric compare).
16
+ * Ignores prerelease tags; returns false for malformed input. NEVER throws.
17
+ */
18
+ export declare function isNewer(latest: string, current: string): boolean;
19
+ /**
20
+ * True when the environment permits an update check: interactive TTY, no CI, no opt-out.
21
+ * Independent of NO_COLOR (color is a separate concern).
22
+ */
23
+ export declare function shouldCheck(opts: {
24
+ isTty: boolean;
25
+ env: NodeJS.ProcessEnv;
26
+ json?: boolean;
27
+ }): boolean;
28
+ /**
29
+ * Render a tasteful 2-line update notice. Uses the repo's own ANSI helpers when color is true;
30
+ * emits no escape codes when color is false.
31
+ */
32
+ export declare function renderUpdateNotice(current: string, latest: string, color: boolean): string;
33
+ /**
34
+ * The single entry point cli.ts calls. Non-blocking, non-throwing.
35
+ *
36
+ * 1. If not interactive / CI / opt-out → return silently.
37
+ * 2. Read cache → if latest > current → print notice to stderr.
38
+ * 3. If cache stale or missing → fire-and-forget a detached child to refresh it.
39
+ */
40
+ export declare function maybeNotifyAndRefresh(opts: {
41
+ current: string;
42
+ isTty: boolean;
43
+ env: NodeJS.ProcessEnv;
44
+ stderr: NodeJS.WritableStream;
45
+ json?: boolean;
46
+ }): void;
@@ -0,0 +1,182 @@
1
+ // Non-blocking "update available" notifier for the litcodex CLI (zero deps).
2
+ //
3
+ // Two roles:
4
+ // 1. Library — imported by cli.ts to read a cached version check and maybe print a notice.
5
+ // 2. CLI entrypoint — when invoked with `--refresh <current>`, fetches the latest version from
6
+ // npm and writes the cache. Runs as a detached child; never blocks the parent CLI.
7
+ //
8
+ // The notice you SEE comes from the PREVIOUS run's background check — never from a blocking
9
+ // network call. Offline-safe, CI-silent, pipe-silent, opt-out via LITCODEX_NO_UPDATE_CHECK.
10
+ import { spawn } from "node:child_process";
11
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { get } from "node:https";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { bold, orange } from "./ui.js";
17
+ // ── Cache ───────────────────────────────────────────────────────────────────
18
+ /** Path to the on-disk version-check cache. */
19
+ export const CACHE_PATH = join(homedir(), ".litcodex", "update-check.json");
20
+ /** Re-check at most once every 24 hours. */
21
+ export const STALE_MS = 24 * 60 * 60 * 1000;
22
+ /**
23
+ * Read and parse the cached update-check result. Returns null for missing, corrupt, or
24
+ * unreadable files. NEVER throws.
25
+ */
26
+ export function readUpdateCache(path) {
27
+ try {
28
+ const raw = readFileSync(path ?? CACHE_PATH, "utf8");
29
+ const parsed = JSON.parse(raw);
30
+ if (typeof parsed === "object" &&
31
+ parsed !== null &&
32
+ typeof parsed.checkedAt === "number" &&
33
+ typeof parsed.latest === "string") {
34
+ return parsed;
35
+ }
36
+ return null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ // ── Semver compare ──────────────────────────────────────────────────────────
43
+ /**
44
+ * True when `latest` is strictly newer than `current` (major.minor.patch numeric compare).
45
+ * Ignores prerelease tags; returns false for malformed input. NEVER throws.
46
+ */
47
+ export function isNewer(latest, current) {
48
+ try {
49
+ const parse = (v) => {
50
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
51
+ if (!m)
52
+ return null;
53
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
54
+ };
55
+ const l = parse(latest);
56
+ const c = parse(current);
57
+ if (!l || !c)
58
+ return false;
59
+ for (let i = 0; i < 3; i++) {
60
+ if (l[i] > c[i])
61
+ return true;
62
+ if (l[i] < c[i])
63
+ return false;
64
+ }
65
+ return false;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ // ── Gating ──────────────────────────────────────────────────────────────────
72
+ /**
73
+ * True when the environment permits an update check: interactive TTY, no CI, no opt-out.
74
+ * Independent of NO_COLOR (color is a separate concern).
75
+ */
76
+ export function shouldCheck(opts) {
77
+ if (!opts.isTty)
78
+ return false;
79
+ if (opts.env["CI"])
80
+ return false;
81
+ if (opts.env["NO_UPDATE_NOTIFIER"])
82
+ return false;
83
+ if (opts.env["LITCODEX_NO_UPDATE_CHECK"])
84
+ return false;
85
+ if (opts.json)
86
+ return false;
87
+ return true;
88
+ }
89
+ // ── Notice rendering ────────────────────────────────────────────────────────
90
+ /**
91
+ * Render a tasteful 2-line update notice. Uses the repo's own ANSI helpers when color is true;
92
+ * emits no escape codes when color is false.
93
+ */
94
+ export function renderUpdateNotice(current, latest, color) {
95
+ const arrow = "→";
96
+ const latestStyled = color ? bold(orange(latest, true), true) : latest;
97
+ const line1 = `\u{1F525} Update available ${current} ${arrow} ${latestStyled}`;
98
+ const line2 = ` Run npm i -g litcodex-ai@latest (or npx --yes litcodex-ai@latest …)`;
99
+ return `\n${line1}\n${line2}\n\n`;
100
+ }
101
+ // ── Orchestrator ────────────────────────────────────────────────────────────
102
+ /**
103
+ * The single entry point cli.ts calls. Non-blocking, non-throwing.
104
+ *
105
+ * 1. If not interactive / CI / opt-out → return silently.
106
+ * 2. Read cache → if latest > current → print notice to stderr.
107
+ * 3. If cache stale or missing → fire-and-forget a detached child to refresh it.
108
+ */
109
+ export function maybeNotifyAndRefresh(opts) {
110
+ try {
111
+ if (!shouldCheck({ isTty: opts.isTty, env: opts.env, ...(opts.json ? { json: true } : {}) }))
112
+ return;
113
+ const cache = readUpdateCache();
114
+ if (cache && isNewer(cache.latest, opts.current)) {
115
+ const color = !opts.env["NO_COLOR"];
116
+ opts.stderr.write(renderUpdateNotice(opts.current, cache.latest, color));
117
+ }
118
+ const stale = !cache || Date.now() - cache.checkedAt > STALE_MS;
119
+ if (stale) {
120
+ const scriptPath = fileURLToPath(new URL("./update-check.js", import.meta.url));
121
+ const child = spawn(process.execPath, [scriptPath, "--refresh", opts.current], {
122
+ detached: true,
123
+ stdio: "ignore",
124
+ });
125
+ child.unref();
126
+ }
127
+ }
128
+ catch {
129
+ // Never break the CLI for an update check failure.
130
+ }
131
+ }
132
+ // ── Background refresh child ────────────────────────────────────────────────
133
+ /**
134
+ * When invoked as `node dist/update-check.js --refresh <current>`, fetch the latest version
135
+ * from npm and write the cache. All errors are swallowed; the process always exits 0.
136
+ */
137
+ function refreshAndExit() {
138
+ const TIMEOUT_MS = 3000;
139
+ const url = "https://registry.npmjs.org/litcodex-ai/latest";
140
+ const req = get(url, { timeout: TIMEOUT_MS }, (res) => {
141
+ let body = "";
142
+ res.on("data", (chunk) => {
143
+ body += chunk.toString();
144
+ });
145
+ res.on("end", () => {
146
+ try {
147
+ const parsed = JSON.parse(body);
148
+ if (typeof parsed !== "object" || parsed === null)
149
+ return;
150
+ const version = parsed.version;
151
+ if (typeof version !== "string")
152
+ return;
153
+ writeCache({ checkedAt: Date.now(), latest: version });
154
+ }
155
+ catch {
156
+ // Parse failure — swallow.
157
+ }
158
+ });
159
+ });
160
+ req.on("error", () => {
161
+ // Network error — swallow.
162
+ });
163
+ req.on("timeout", () => {
164
+ req.destroy();
165
+ });
166
+ }
167
+ function writeCache(data) {
168
+ try {
169
+ const dir = dirname(CACHE_PATH);
170
+ mkdirSync(dir, { recursive: true });
171
+ const tmp = `${CACHE_PATH}.tmp.${process.pid}`;
172
+ writeFileSync(tmp, JSON.stringify(data), "utf8");
173
+ renameSync(tmp, CACHE_PATH);
174
+ }
175
+ catch {
176
+ // fs failure — swallow.
177
+ }
178
+ }
179
+ // ── CLI entrypoint guard ────────────────────────────────────────────────────
180
+ if (process.argv.includes("--refresh")) {
181
+ refreshAndExit();
182
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litcodex/lit-loop",
3
- "version": "0.3.3",
3
+ "version": "0.3.6",
4
4
  "description": "LitCodex Lit-Loop runtime: durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit.",
5
5
  "type": "module",
6
6
  "bin": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "litcodex-ai",
3
- "version": "0.3.3",
3
+ "version": "0.3.6",
4
4
  "description": "Codex loop harness installer. Run `npx litcodex-ai install` to set up the LitCodex Codex platform: the bare `lit` hook and the durable lit-loop runtime.",
5
5
  "keywords": ["codex", "litcodex", "lit-loop", "ai-agents", "orchestration"],
6
6
  "author": "LitCodex Authors",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "files": ["bin", "dist", "model-catalog.json", "README.md", "LICENSE"],
17
17
  "dependencies": {
18
- "@litcodex/lit-loop": "0.3.3"
18
+ "@litcodex/lit-loop": "0.3.6"
19
19
  },
20
20
  "bundledDependencies": ["@litcodex/lit-loop"],
21
21
  "scripts": {