Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ For mocks and spies use `vi`: `vi.fn()` to create a mock function, `vi.spyOn(obj

When removing a string, label, or branch, don't pin its absence with `expect(...).not.toContain("removed string")`. The string is no longer anywhere in the source — nothing realistic could put it back — so the assertion only documents history. Update or delete the positive assertion instead. Negative assertions remain legitimate when the string is still emitted by **another branch of the same render**: e.g., a Confirm test that asserts the "no backup yet" arrow does NOT appear when rendering the "backup already exists" branch is pinning a conditional, not a deleted feature.

**Never poll `lastFrame()` for something an app renders just before it exits.** Ink writes an **empty** frame when it unmounts, so `lastFrame()` returns `""` from that moment on. A component that shows its final message and then calls `exit()` — `SkillPushApp`/`SkillPullApp` refusing without raw mode do it after 20ms — leaves a 20ms window that a 20ms poll has to land in; miss it and the predicate can never become true again, so the test burns its whole budget and reports a hang. That was a real full-suite flake (~1 in 8, never reproducible in isolation, and it survived a 10s budget). Use `tests/helpers/raw-mode.tsx#lastNonEmptyFrame(instance.frames)` instead — it is stable once the app is gone. Reproduce the old failure by awaiting a `setTimeout(400)` before the first poll.

Note the whole frame *history* (`frames.join`) is the wrong unit for a negative assertion: a prompt legitimately renders for one frame before the effect that replaces it, so `SkillPullApp`'s `not.toContain("❯ ")` would fail against the history while being exactly right against the settled frame. History is fine for "did this ever appear"; settled frame for "what is the user left looking at".

## Backup behavior

`configureClaudeCode` and `configureOpenCode` always replace the live config (`~/.claude/settings.json`, `~/.config/opencode/opencode.json`), but an existing `*.backup` is never overwritten. On the first run a backup is copied from the live config; every subsequent run skips the backup step and leaves the original `*.backup` in place. There is no prompt and no `overwriteBackups` option — preserving the user's pre-CoDev state is the whole point. `restoreTool` then renames `*.backup` back over the live file.
Expand Down Expand Up @@ -138,7 +142,7 @@ The load-bearing consequence: the provider id **is** CoDev's authorship marker f

Every agent CoDev configures has to be *told* the window of the model it's talking to. The gateway serves custom models none of them recognize, and each guesses differently when unconfigured: Codex assumes a 272K fallback, OpenCode assumes context `0` (which disables compaction outright), Continue falls back to a generic default. `src/lib/model-limits.ts` is the single source of truth; the four writers in `configure.ts` translate it into each agent's own knob and hold no window constants of their own. The flat `GATEWAY_CONTEXT_WINDOW` / `GATEWAY_COMPACT_*` constants that used to live in `const.ts` are gone — they encoded the assumption that every gateway model shares one 196608-token window, which stopped being true once the gateway served both a 1M-token and a 200K-token model.

`ModelLimits` is `{ context, trigger, output? }`: the true window, the absolute token count where auto-compaction should fire, and an optional output cap. **`trigger` is explicit rather than a percentage** — the gap between window and trigger is a per-model judgement call, not a constant. `limitsFor(modelId)` resolves **remote → table → `DEFAULT_LIMITS`**, where remote is the gateway's own numbers cached in auth.json and `DEFAULT_LIMITS` (200K/160K) covers anything unrecognized. `MiniMax/MiniMax-M2.7` is deliberately *absent* from the table: the default already describes it, and an entry that merely restates the default is one more thing to keep in sync.
`ModelLimits` is `{ context, trigger, output? }`: the true window, the absolute token count where auto-compaction should fire, and an optional output cap. **`trigger` is explicit rather than a percentage** — the gap between window and trigger is a per-model judgement call, not a constant. `limitsFor(modelId)` resolves **remote → table → `DEFAULT_LIMITS`**, where remote is the gateway's own numbers cached in auth.json and `DEFAULT_LIMITS` (200K/160K) covers anything unrecognized. The 200K sibling of the 1M model is deliberately *absent* from the table: the default already describes it, and an entry that merely restates the default is one more thing to keep in sync. That 1M model's id lives in `model-limits.ts` as `M3_ID`, `atob`-encoded for the same reason `FALLBACK_MODEL` is in `const.ts` — the upstream vendor's name stays out of the shipped bundle, so keep it that way when adding table entries.

Each agent takes a different shape, and the differences are the whole reason this module exists:

Expand Down Expand Up @@ -276,6 +280,8 @@ The way to land there on Windows is **Git Bash**: MSYS2/mintty pipes stdin throu

**The `Boolean` in `useCanType` is load-bearing.** Node leaves `isTTY` **undefined** on a pipe rather than setting it false, while `useInput` skips raw mode only on `options.isActive === false` — a strict comparison. Forwarding the raw `undefined` reads as "active" and throws the very error the gate exists to prevent. No unit test can catch it, since `ink-testing-library`'s fake stdin sets a real boolean; it was found by running the built CLI with `< /dev/null`, which is the only way to reproduce it. `tests/lib/tty.test.ts` pins `toBe(false)` rather than falsiness for that reason. **Do the same for any new prompt: gate on `useCanType()`, and smoke-test it with piped stdin, not only under vitest.**

**Testing the no-raw-mode path goes through `tests/helpers/raw-mode.tsx#renderWithoutRawMode`.** `ink-testing-library`'s stdin always reports `isTTY: true` and its `render` takes no options, so the flag has to be flipped on the instance and the tree re-rendered — Ink recomputes `isRawModeSupported` every render. The helper renders an **inert tree first** so the flag is already false when the component under test mounts. Four tests used to mount the real component and flip the flag underneath it, which is a state no real terminal reaches: `useInput`'s effect calls `setRawMode(true)` while raw mode still looks available, and `handleSetRawMode` (ink's `components/App.js`) **throws** whenever it runs with `isRawModeSupported` false — including from the cleanup, whose `setRawMode` identity changes at exactly the moment the flag flips. Mounting in the target state sidesteps the whole window and is what the tests claim to be doing anyway.

## Diagnostic logging

`~/.codev-hub` has two log homes — don't mix them up:
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,30 @@ cd your-project
codevhub init # initialize + index the current project (one time)
```

## CoDev Office skills (offline bundle)

`codevhub skill office` installs the CoDev Office skills so your agents can produce and edit real DOCX and XLSX files. Everything ships in one offline bundle — no login and no per-skill downloads:

```bash
codevhub skill office
```

It picks the bundle for your OS, downloads it into `~/.codev-hub/office`, and runs the bundled setup script, which may prompt for `sudo` (macOS/Linux) or UAC (Windows).

**The bundle is large — roughly 610 MB (Linux), 820 MB (Windows) or 1.4 GB (macOS).** The download folder is kept between runs, so an interrupted transfer resumes where it left off and an already-downloaded bundle is reused. To force a completely fresh download, delete `~/.codev-hub/office`.

To fetch the bundle now and install later — or to stage it for a machine that has no internet access:

```bash
codevhub skill office --download-only # download for this OS, don't install
codevhub skill office --platform windows # download another OS's bundle (implies --download-only)
codevhub skill office --dir /media/usb/codev-office # download somewhere else
```

Both files land side by side, and the command prints the exact line to run from that folder (`bash codev-office-<os>-setup.sh`, or `powershell -ExecutionPolicy Bypass -File .\codev-office-windows-setup.ps1`). A bundle downloaded for another OS is never executed on this machine.

Two flags are passed straight through to the setup script: `--minimal` (skip the optional extras) and `--skip-verify` (skip the bundle's own SHA-256 check).

## Switching between self-hosted and proprietary models

CoDev points your agents at a self-hosted AI gateway, but you can flip any agent back to its own provider (Anthropic for Claude Code, OpenAI for Codex, and so on) — and back to the gateway again — whenever you like. Because CoDev backs up your original config before it changes anything, the round-trip is safe and repeatable.
Expand Down
7 changes: 6 additions & 1 deletion src/lib/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ export async function downloadFile(opts: DownloadOptions): Promise<void> {
}
if (res.body === null) throw new Error(`download had no body: ${url}`);

const contentLength = Number(res.headers.get("content-length"));
// A missing header must fall through to opts.size. `Number(null)` is 0, which
// is finite — reading it straight would make the fallback unreachable and
// report a total of `offset` (0 on a fresh download) for every chunked
// response.
const header = res.headers.get("content-length");
const contentLength = header === null ? Number.NaN : Number(header);
const total = Number.isFinite(contentLength)
? offset + contentLength
: (opts.size ?? null);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Skill hub:
(previews and confirms before upload; --draft-only to stop
at DRAFT, --auto-approve for admins, --json for output)
skill office Download the CoDev Office offline skills bundle
(minimax-docx, minimax-xlsx) for this OS and run its
(DOCX and XLSX authoring) for this OS and run its
setup script
(--platform ubuntu|macos|windows to fetch for another OS
[implies --download-only], --dir <path> for the download
Expand Down
15 changes: 11 additions & 4 deletions src/lib/model-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,21 @@ export const DEFAULT_COMPACT_PCT = 80;
// capacity, too large overruns the model and 400s mid-session.
export const DEFAULT_LIMITS: ModelLimits = { context: 200000, trigger: 160000 };

// The 1M-window model's id, encoded for the same reason FALLBACK_MODEL is in
// const.ts: the upstream vendor's name stays out of the shipped bundle. Decodes
// to the exact id `/v1/models` reports; kept separate from FALLBACK_MODEL (which
// happens to be the same string today) so retargeting the fallback can't
// silently move this window onto another model.
const M3_ID = atob("TWluaU1heC9NaW5pTWF4LU0z");

// Known gateway models. Keyed by the exact id `/v1/models` reports, which is
// what lands in every agent config.
//
// MiniMax/MiniMax-M2.7 is deliberately absent: DEFAULT_LIMITS already describes
// it correctly, and an entry that merely restates the default is one more thing
// to keep in sync.
// The 200K sibling of M3_ID is deliberately absent: DEFAULT_LIMITS already
// describes it correctly, and an entry that merely restates the default is one
// more thing to keep in sync.
const TABLE: Record<string, ModelLimits> = {
"MiniMax/MiniMax-M3": { context: 1000000, trigger: 800000 },
[M3_ID]: { context: 1000000, trigger: 800000 },
"zai-org/GLM-4.7-cc": { context: 200000, trigger: 160000 },
};

Expand Down
12 changes: 9 additions & 3 deletions src/lib/office.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { officeDownloadsDir } from "@/lib/paths.js";

// `codevhub skill office`: fetch the CoDev Office offline bundle (published by
// the codev-storage MinIO backend) for this OS and run the bundled setup
// script, which installs the Office skills (minimax-docx, minimax-xlsx, …).
// script, which installs the Office skills (DOCX/XLSX authoring, …).
// File names are deterministic per platform — no manifest fetch — and each
// bundle carries its own SHA256SUMS.txt that the setup flow can verify.
// Non-interactive on purpose — the second half hands the terminal to an
Expand Down Expand Up @@ -130,7 +130,9 @@ function makeProgressPrinter(name: string): {
let lastPercent = -5;
return {
print(received, total) {
if (total === null) {
// `0` as well as `null`: a zero total is either an unknown length or an
// empty body, and dividing by it renders NaN%/Infinity%.
if (total === null || total === 0) {
if (tty) process.stderr.write(`\r${name} ${formatSize(received)}`);
return;
}
Expand Down Expand Up @@ -238,8 +240,12 @@ export async function runSkillOffice(

// Without per-file checksums an existing file is reused as-is. That's the
// point for the GB-scale bundle, but the setup script is tiny and must
// track the published version — always refetch it.
// track the published version — always refetch it. The `.partial` goes too:
// downloadFile resumes from it via Range, so a leftover from an interrupted
// run would splice stale bytes onto a script that has since been republished
// — and with no expected checksum, nothing would catch it.
rmSync(join(dir, script), { force: true });
rmSync(join(dir, `${script}.partial`), { force: true });

for (const name of [script, bundle]) {
const progress = makeProgressPrinter(name);
Expand Down
13 changes: 5 additions & 8 deletions tests/DoctorApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as log from "@/lib/log.js";
import { PROXY_APPLIED_ENV } from "@/lib/proxy.js";
import * as reexec from "@/lib/reexec.js";
import * as tls from "@/lib/tls.js";
import { renderWithoutRawMode } from "./helpers/raw-mode.js";

vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
Expand Down Expand Up @@ -264,14 +265,10 @@ describe("DoctorApp", () => {
);
stubHappyPath();

// ink-testing-library's stdin always reports isTTY true and takes no
// options; Ink recomputes isRawModeSupported every render, so flipping the
// flag and re-rendering reproduces that terminal. This lands before the
// async environment group resolves, so the network phase reads the new
// value.
const instance = render(<DoctorApp />);
instance.stdin.isTTY = false;
instance.rerender(<DoctorApp />);
// A terminal that can't supply keystrokes — see helpers/raw-mode.tsx for
// why the flag has to be false before DoctorApp mounts rather than flipped
// underneath it.
const instance = renderWithoutRawMode(<DoctorApp />);

await waitForFrame(instance.frames, "check(s) failed");
const output = instance.frames.join("\n");
Expand Down
36 changes: 19 additions & 17 deletions tests/SkillPullApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { SkillAgent } from "@/lib/skill-dirs.js";
import * as install from "@/lib/skill-install.js";
import * as skillhub from "@/lib/skillhub.js";
import { SkillPullApp } from "@/SkillPullApp.js";
import { lastNonEmptyFrame, renderWithoutRawMode } from "./helpers/raw-mode.js";

const ESC = String.fromCharCode(27);
const DOWN = `${ESC}[B`;
Expand Down Expand Up @@ -261,29 +262,30 @@ describe("SkillPullApp", () => {
});
});

// A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts). The
// dispatcher normally routes those to the plain runner, so this covers the
// case where Ink's stdin isn't the process's own. Unlike an ungated useInput
// (which throws), an unanswerable picker would just hang forever.
// ink-testing-library's stdin reports isTTY true and takes no options, so the
// flag is flipped and the tree re-rendered — Ink recomputes
// `isRawModeSupported` every render.
// A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts and
// helpers/raw-mode.tsx). The dispatcher normally routes those to the plain
// runner, so this covers the case where Ink's stdin isn't the process's own.
// Unlike an ungated useInput (which throws), an unanswerable picker would
// just hang forever.
test("without raw mode: explains the missing keyboard instead of prompting", async () => {
mockResolve();
const spy = vi.spyOn(install, "installResolvedSkill");
const onDone = vi.fn();
const node = (
<SkillPullApp target={ID} force={false} json={false} onDone={onDone} />
);

const instance = render(node);
instance.stdin.isTTY = false;
instance.rerender(node);

await waitFor(() =>
frameText(instance.lastFrame).includes("cannot supply keystrokes"),
const instance = renderWithoutRawMode(
<SkillPullApp target={ID} force={false} json={false} onDone={onDone} />,
);
const frame = frameText(instance.lastFrame);

// The last *non-empty* frame, not lastFrame(): the message is shown and the
// app then exits ~20ms later, and Ink writes an empty frame on unmount. A
// poll on lastFrame() has to sample inside that 20ms window or it sees ""
// for the rest of the run — which is how this test flaked under load. The
// whole history would be wrong here: the picker does render for one frame
// before the effect replaces it, so `not.toContain("❯ ")` is an assertion
// about what the user is left looking at.
const settled = () => stripAnsi(lastNonEmptyFrame(instance.frames));
await waitFor(() => settled().includes("cannot supply keystrokes"));
const frame = settled();
expect(frame).toContain("--here, --global, or --dir");
// Never falls back to a location the user didn't choose.
expect(frame).not.toContain("❯ ");
Expand Down
27 changes: 13 additions & 14 deletions tests/SkillPushApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as auth from "@/lib/auth.js";
import * as publish from "@/lib/skill-publish.js";
import * as skillhub from "@/lib/skillhub.js";
import { SkillPushApp } from "@/SkillPushApp.js";
import { lastNonEmptyFrame, renderWithoutRawMode } from "./helpers/raw-mode.js";

const ESC = String.fromCharCode(27);
const stripAnsi = (s: string) =>
Expand Down Expand Up @@ -189,11 +190,9 @@ describe("SkillPushApp login gate", () => {
expect(pub).not.toHaveBeenCalled();
});

// A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts). The
// confirm step is the last thing between the user and an upload, so silence
// must never be read as consent. ink-testing-library's stdin reports isTTY
// true and takes no options, so the flag is flipped and the tree re-rendered
// — Ink recomputes `isRawModeSupported` every render.
// A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts and
// helpers/raw-mode.tsx). The confirm step is the last thing between the user
// and an upload, so silence must never be read as consent.
test("without raw mode: refuses rather than publishing unconfirmed", async () => {
const authSpy = vi
.spyOn(skillhub, "hasSkillhubAuth")
Expand All @@ -202,23 +201,23 @@ describe("SkillPushApp login gate", () => {
vi.spyOn(publish, "preparePublishArchive").mockResolvedValue(ARCHIVE);
const onDone = vi.fn();

const node = (
const instance = renderWithoutRawMode(
<SkillPushApp
path="./pg-tuner"
json={false}
draftOnly={false}
autoApprove={false}
onDone={onDone}
/>
/>,
);
const instance = render(node);
instance.stdin.isTTY = false;
instance.rerender(node);

await waitFor(() =>
frameText(instance.lastFrame).includes("cannot supply keystrokes"),
);
expect(frameText(instance.lastFrame)).toContain("--json");
// The last *non-empty* frame, not lastFrame(): the refusal is shown and the
// app then exits ~20ms later, and Ink writes an empty frame on unmount. A
// poll on lastFrame() has to sample inside that 20ms window or it sees ""
// for the rest of the run — which is how this test flaked under load.
const settled = () => stripAnsi(lastNonEmptyFrame(instance.frames));
await waitFor(() => settled().includes("cannot supply keystrokes"));
expect(settled()).toContain("--json");
expect(authSpy).not.toHaveBeenCalled();
expect(pub).not.toHaveBeenCalled();
await waitFor(() => onDone.mock.calls.length > 0);
Expand Down
Loading
Loading