# KiCI CLI: authoring on your own machine This bundle covers: Running the CLI locally: compile, test, run local, hooks, lock-file drift, common failures. ## CLI reference Source: https://docs.kici.dev/user/cli-reference/ The `@kici-dev/compiler` package provides the `kici` CLI for compiling, testing, and managing workflows. ## Installation ```bash pnpm add -D @kici-dev/compiler ``` The examples use pnpm, but npm and yarn work too — `npm install -D @kici-dev/compiler` or `yarn add -D @kici-dev/compiler`. Run commands with `npx kici` or add scripts to your `package.json`: ```json { "scripts": { "kici:compile": "kici compile", "kici:preview": "kici preview" } } ``` ## Command reference The full command reference is split by area: - [Authoring & local dev](https://docs.kici.dev/user/cli/authoring-and-local/) — `compile`, `preview`, `local`, `fixture`, `types`, `workflows`, `hook`, `docs` - [Runs & approvals](https://docs.kici.dev/user/cli/runs-and-approvals/) — `run`, `runs`, `reject`, `approve` - [Account & org](https://docs.kici.dev/user/cli/account-and-org/) — `login`, `logout`, `init`, `org`, `pat`, `secrets`, `admin`, `orchestrators`, `endpoints` - [Notifications & diagnostics](https://docs.kici.dev/user/cli/notifications-and-diagnostics/) — `notifications`, `verify-attestation`, `diagnostics`, `doctor`, `report`, `feedback` Each area page carries a `## Guide` section (worked examples and command-by-command narrative) and a `## Reference` section (the always-current generated signature list for that area's commands). ## Workflow discovery The CLI discovers workflows by scanning `.kici/workflows/*.ts` (or `.mjs` in MJS mode). Each file should `export default` a single workflow: ```typescript // .kici/workflows/ci.ts import { workflow, job, step, pr } from '@kici-dev/sdk'; export default workflow('ci', { on: pr(), jobs: [/* ... */], }); ``` Multiple workflow files are supported -- each becomes a separate workflow in `kici.lock.json`. ## Lock file The `kici compile` command produces `.kici/kici.lock.json` inside the `.kici` directory. This file: - Contains all workflow definitions in a portable JSON format - Is used by the orchestrator to evaluate triggers without code checkout - Should be committed to version control - Is regenerated on every `kici compile` run Use `kici compile --check` in CI to validate that workflows are correct without writing files. For the full story on drift, pre-commit/CI, and agent-side verification, see [Lock file and workflow drift](https://docs.kici.dev/user/lock-file-and-drift/). ## Exit codes Most commands follow a two-value convention: | Code | Meaning | | ---- | -------------------- | | 0 | Success | | 1 | Failure (see output) | Two cases add a third code: - `kici doctor` grades its checks: `0` when every check passes, `1` when any check warns, `2` when any check fails. - A usage error exits `2` — mutually exclusive flags on `kici run remote` (`--pick` combined with a fixture name, `--all`, or `--workflow`), `--fail-on-drift` without `--check` on the same command, or invoking a retired command such as `kici run local`. Each area page documents the exit codes of the commands it covers. ## Debug output Use `--debug` (on `kici run --local`, `kici run remote`, `kici preview`) or `--verbose` (on `kici compile`) for detailed output: ```bash # Shows trigger matching, rule evaluation, decision traces kici run push --local --debug # Shows detailed compilation steps kici compile --verbose # Shows trigger matching preview kici preview pr:open --debug ``` Set `KICI_DEBUG=true` for additional internal debug output across all commands. ## Environment variables | Variable | Description | | ------------ | ---------------------------------------------------------------------------------------------------------- | | `KICI_DEV` | Set to `true` for development mode | | `KICI_DEBUG` | Set to `true` for verbose internal output | | `CI` | Disables interactive prompts unless it is set to an opt-out value (`0` or `false`, any case) or left empty | See [Environment variables](https://docs.kici.dev/user/env-vars/#how-ci-is-interpreted) for the full CI-detection convention, including the `GITHUB_ACTIONS` and `GITLAB_CI` markers. ## See also - [Getting started](https://docs.kici.dev/user/getting-started/) -- install the SDK and write your first workflow - [Testing guide](https://docs.kici.dev/user/testing-guide/) -- writing fixtures, remote test runs, secret contexts, and repo state transfer - [SDK reference](https://docs.kici.dev/user/sdk-reference/) -- complete API for the workflow definitions that the CLI compiles - [Workflow patterns](https://docs.kici.dev/user/workflow-patterns/) -- example workflows to compile and test with these commands --- ## Common failures Source: https://docs.kici.dev/user/common-failures/ When a run misbehaves, start here. Each section below is a **symptom you can observe** (a message, a missing run, a stuck job), the **cause** behind it, the **one command** that confirms the diagnosis, and the **fix**. Everything on this page uses the developer tools you already have — the `kici` CLI and the dashboard. For orchestrator-side diagnostics (scaler spawn failures, the webhook delivery log, agent registration internals), your operator has a deeper companion at [Operator troubleshooting](https://docs.kici.dev/operator/troubleshooting/). ## Fast triage | You see... | Jump to | | -------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | A run finishes with `No jobs dispatched` | [No jobs dispatched](https://docs.kici.dev/user/common-failures/#no-jobs-dispatched) | | A run fails complaining the lock file is stale or incompatible | [Lock-file drift](https://docs.kici.dev/user/common-failures/#lock-file-drift) | | You pushed but no run ever appears | [The webhook never arrives](https://docs.kici.dev/user/common-failures/#the-webhook-never-arrives) | | A run is stuck "queued" and no agent ever picks it up | [The agent won't connect](https://docs.kici.dev/user/common-failures/#the-agent-wont-connect) | | A `commitMessage`-gated workflow stops running for some events | [A `commitMessage` filter never evaluates](https://docs.kici.dev/user/common-failures/#a-commitmessage-filter-never-evaluates) | ## No jobs dispatched **Symptom.** A trigger matched a workflow, but the run ends immediately with `No jobs dispatched (all matched workflows had no static jobs or dispatch was rejected)`, or a job sits queued and the dashboard shows `No matching agent available`. **Cause.** A job's `runsOn` label set matches **no agent** the orchestrator's scaler can provide. The orchestrator evaluates the lock file, finds the matching workflow, but no online (or spawnable) agent carries the requested labels, so it has nothing to dispatch to. The most common trigger is a **GitHub-hosted runner label**. KiCI agents are labeled with `kici:os:linux`, `kici:arch:*`, and whatever custom labels your scaler declares (for example `container` or `bare-metal`). A value like `ubuntu-latest` is a GitHub label — it matches no KiCI agent and never dispatches. **Diagnose.** Two commands: - `kici diagnostics` lists every connected orchestrator, its scalers, and the labels its agents report. Confirm an agent (or a scaler that can spawn one) actually carries the label your job asks for. - `kici preview push --branch main` shows which workflows and jobs a trigger would match, without executing anything — use it to see the `runsOn` your job resolves to. **Fix.** Set the job's `runsOn` to a label your scaler provides — an auto-label every agent reports (`'kici:os:linux'`) or a custom scaler label (`'container'`) — then `kici compile` and push (or re-run). See [the `runsOn` forms](https://docs.kici.dev/user/sdk/core/#runson-forms) for how a job selects agents by label. ## Lock-file drift **Symptom.** A run fails at init (before any step runs) with one of: - `Lock file is out of date: workflow source changed without regenerating kici.lock.json ...` - a schema-version message for a lock **older** than the orchestrator's compatibility window: `Lock file schema vX predates the oldest supported version vY — recompile with a current SDK ('kici compile') and push again.` - a schema-version message for a lock **newer** than the window, naming the orchestrator version it needs: `Lock file requires orchestrator schema vX or newer but this orchestrator understands up to vY — upgrade the orchestrator to a newer version.` - a "stale or compiled by an older engine — recompile with `kici compile`" message about an invalid label matcher. **Cause.** The committed `kici.lock.json` no longer matches the workflow source at that commit, or it was compiled by a different toolchain version than the one your orchestrator and agents run. KiCI reads only the lock file to route triggers, and the agent re-verifies the workflow source hash before running, so any mismatch is rejected loudly rather than run against stale routing. **Diagnose.** From the workflow repo, recompile and check whether the lock file changes: kici compile If `kici.lock.json` shows up as modified in `git status` afterward, it was out of date. The orchestrator reads a compatibility window of lock schema versions, so a schema message means your lock fell outside that window: a lock **below the floor** is too old and must be recompiled against your current toolchain, while a lock **above the window** was compiled by an SDK newer than your orchestrator and needs the orchestrator upgraded. **Fix.** For a too-old lock, run `kici compile`, commit the regenerated `kici.lock.json`, and push again. For a lock that needs a newer reader, upgrade the orchestrator to the version the error names. Never force an out-of-window lock through. Full detail on the two-artifact model, the compatibility window, and how the hashes are computed is in [Lock file and workflow drift](https://docs.kici.dev/user/lock-file-and-drift/). ## The webhook never arrives **Symptom.** You pushed a commit (or opened a PR), but **no run appears** in the dashboard at all. **Cause.** One of two things: the provider never delivered the webhook to the Platform, or the orchestrator received it but **nothing matched** — no workflow triggered, or the repo had no lock file at that commit. **Diagnose.** Work from the outside in: 1. **Did the provider deliver?** In your GitHub App's settings, open the **Recent deliveries** tab and look for non-2xx responses. A 4xx there means the delivery was rejected before any workflow ran (usually a signature or source-registration mismatch). 2. **Did anything match?** Run `kici preview push --branch ` against your workflow. If it reports no matching workflow, your triggers don't cover that event/branch — the push was delivered and matched nothing. 3. **Was there a lock file?** A repository with **no** `kici.lock.json` at the pushed commit produces no run and is not an error. Confirm the lock file is committed and current (see [Lock-file drift](https://docs.kici.dev/user/common-failures/#lock-file-drift)). The dashboard's Event log (under your org settings) records each delivery and whether it matched, was a duplicate, or found no lock file — check it to see which of the above happened. **Fix.** Depending on which step failed: re-check the webhook secret and source registration (a 4xx delivery), broaden the workflow's triggers (`kici preview` matched nothing), or commit the lock file (no lock at that commit). The [Docker/Podman quickstart troubleshooting](https://docs.kici.dev/user/quickstart/compose/#troubleshooting) walks through the provider-side wiring in detail. ## The agent won't connect **Symptom.** A job sits queued and no agent ever picks it up, or the orchestrator logs show an agent connecting and immediately dropping. **Cause.** Usually one of: - **Authentication.** The agent's token is wrong or was revoked. The orchestrator closes the connection and the agent does **not** retry a bad token, so it never registers. - **An ID conflict.** Two agents registered with the same agent ID but different tokens, and the later one is refused. - **Provisioning.** An ephemeral agent failed to start before it could connect — a missing binary (`spawn node ENOENT` on a bare-metal scaler), an image that won't pull, or a microVM that won't boot. No step logs exist because the agent never ran. **Diagnose.** `kici diagnostics` shows whether any agent is currently registered and what its scalers report. If the failure is a provisioning one (no agent ever came up), the captured error surfaces as the run's failure reason and in the dashboard's **Provisioning logs**. Your operator can confirm the backend could not spawn an agent with `kici-admin diagnose` — the `scaler:` row carries the captured error. **Fix.** For a token problem, mint a fresh agent token and restart the agent. For a provisioning problem, the fix is on the orchestrator host (the missing binary, the unpullable image) — hand this to your operator with the run's failure reason. The full provisioning-failure playbook is in [Operator troubleshooting](https://docs.kici.dev/operator/troubleshooting/). ## A `commitMessage` filter never evaluates **Symptom.** A workflow gated on a `commitMessage` trigger filter stops running for some events, even though the message looks like it should match. **Cause.** The event carries no commit message. A branch-deletion push has no head commit, and a self-hosted forge (Gogs, or a GitLab source) may publish none at the configured path. The filter is **fail-visible**: when it cannot read a message, the workflow does not run rather than running ungated. **Diagnose.** The decision trace records the `commitMessage` check with the verdict `indeterminate` and the reason `no commit message in payload`. That is distinct from an `excluded` verdict, which the message itself caused. **Fix.** For a self-hosted forge, set the source's `commitMessage` payload path so the orchestrator can read the head commit's message. A branch-deletion push genuinely carries no message and is expected not to match. ## When to escalate to your operator The failures above are ones you can resolve from your workflow repo and the `kici` CLI. Anything that lives on the orchestrator host — scaler configuration, the webhook delivery log, agent registration internals, database or storage problems — belongs to whoever operates your orchestrator. Point them at [Operator troubleshooting](https://docs.kici.dev/operator/troubleshooting/) and include the run's failure reason (from the dashboard run detail or `kici runs show `). --- ## Lifecycle hooks Source: https://docs.kici.dev/user/hooks/ Hooks are callbacks that run at specific points in the execution lifecycle. They let you react to outcomes (cancellation, success, failure) and perform cleanup without affecting the execution flow. ## Hook types KiCI supports six hook types at three levels (step, job, workflow): | Hook | When it runs | Available on | | ------------ | ------------------------------------ | ------------------- | | `onCancel` | After step/job/workflow is cancelled | Step, Job, Workflow | | `cleanup` | Always (success, failure, or cancel) | Step, Job, Workflow | | `onSuccess` | After job/workflow succeeds | Job, Workflow | | `onFailure` | After job/workflow fails | Job, Workflow | | `beforeStep` | Before each step in a job | Job | | `afterStep` | After each step in a job | Job | ## Basic usage ### Job-level hooks ```typescript import { workflow, job, step, push } from '@kici-dev/sdk'; export default workflow('deploy', { on: push({ branches: ['main'] }), jobs: [ job('deploy-prod', { runsOn: 'linux', steps: [ step('deploy', async ({ $ }) => { await $`kubectl apply -f manifests/`; }), ], onCancel: async (ctx) => { console.log(`Deploy cancelled: ${ctx.outcome.reason}`); await ctx.$`kubectl rollout undo deployment/app`; }, cleanup: async (ctx) => { // Always runs -- release lock, notify team, etc. await ctx.$`curl -X POST https://slack.com/webhook -d '{"text": "Deploy ${ctx.outcome.status}"}'`; }, onSuccess: async (ctx) => { console.log(`Deploy succeeded in ${ctx.outcome.duration}ms`); }, onFailure: async (ctx) => { console.log(`Deploy failed at step: ${ctx.outcome.failedStep}`); }, gracePeriod: 60, // 60 seconds before SIGKILL on cancel }), ], }); ``` ### Step-level hooks ```typescript step('download-artifacts', { run: async ({ $ }) => { await $`wget https://artifacts.example.com/build.tar.gz`; }, onCancel: async (ctx) => { // Clean up partial downloads await ctx.$`rm -f build.tar.gz`; }, cleanup: async (ctx) => { await ctx.$`rm -rf /tmp/staging`; }, }); ``` ### Workflow-level hooks ```typescript workflow('ci', { on: push({ branches: ['main'] }), jobs: [/* ... */], onCancel: async (ctx) => { // Notify when any job in the workflow is cancelled console.log('CI workflow cancelled'); }, cleanup: async (ctx) => { // Always runs after all jobs complete console.log(`CI workflow finished with status: ${ctx.outcome.status}`); }, }); ``` ## Hook context Hook functions receive the same `StepContext` as regular steps (`$`, `ctx`, `log`, `env`), plus an `outcome` object with metadata about the execution result. ### ctx.outcome ```typescript interface OutcomeMetadata { /** Final status of the job/workflow. */ status: 'cancelled' | 'success' | 'failed'; /** Reason for cancellation (e.g., "User requested", "Superseded by run #42"). */ reason?: string; /** Name of the step that caused failure (for onFailure hooks). */ failedStep?: string; /** Outputs from all completed steps. */ stepOutputs: Record; /** Total execution duration in milliseconds. */ duration: number; } ``` ### Capabilities Hooks can do everything regular steps can: - Run shell commands via `$` - Set environment variables via `ctx.setEnv()` and prepend to `PATH` via `ctx.addPath()` - Access previous step outputs via `ctx.outputsOf()` and `ctx.jobOutputs()` - Publish encrypted secret outputs via `ctx.setSecretOutput()` - Log via `log.info()`, `log.error()`, etc. ## Hook timeout Each hook has a timeout (default: 5 minutes). You can customize it per-hook: ```typescript job('deploy', { runsOn: 'linux', steps: [/* ... */], cleanup: { run: async (ctx) => { await ctx.$`./lengthy-cleanup.sh`; }, timeout: 10 * 60 * 1000, // 10 minutes in ms }, }); ``` ## Hook execution order Hooks execute inside-out on cancellation (like stack unwinding): 1. **Step-level** cleanup (on the cancelled step) 2. **Job-level** onCancel, then cleanup 3. **Workflow-level** onCancel, then cleanup On success: step afterStep (after each step), then job onSuccess + cleanup, then workflow onSuccess + cleanup. On failure: job onFailure + cleanup, then workflow onFailure + cleanup. **cleanup always runs** -- regardless of whether the outcome was success, failure, or cancel. ## Hooks are observers Hooks follow the "one mechanism per concern" principle: - **Rules** control whether a step/job executes (conditional logic) - **Hooks** react to execution outcomes (lifecycle callbacks) Hooks cannot short-circuit step execution or change the execution flow. They observe and respond. ## beforeStep and afterStep These job-level hooks run around every step in the job: ```typescript job('test', { runsOn: 'linux', beforeStep: async (ctx) => { console.log(`Starting step at ${new Date().toISOString()}`); }, afterStep: async (ctx) => { console.log(`Step completed with status: ${ctx.outcome.status}`); }, steps: [ step('lint', async ({ $ }) => { await $`pnpm lint`; }), step('test', async ({ $ }) => { await $`pnpm test`; }), ], }); ``` `afterStep` runs immediately after its step, before the next step starts (not deferred to the end of the job). ## Step-level rules Step-level rules control whether a step executes, evaluated at runtime by the agent: ```typescript import { step, rule, skip, isEventType } from '@kici-dev/sdk'; step('deploy', { run: async ({ $ }) => { await $`kubectl apply -f manifests/`; }, rules: [ rule('only on main pushes', (ctx) => { if (!isEventType(ctx.event, 'push')) return false; return ctx.event.payload.ref === 'refs/heads/main'; }), ], }); // Or use skip() for explicit skip with a reason step('optional-check', { run: async ({ $ }) => { await $`./optional-check.sh`; }, rules: [skip('not needed in CI', () => true)], }); ``` When a rule returns `false`, the step is reported as `skipped` and subsequent steps continue normally. Skipped steps don't cause the job to fail. Step rules have access to runtime context via `RuleContext`: `event` (typed discriminated union), `changedFiles`, `env`, and `$`. Both step-level and job-level rules evaluate agent-side at runtime — not on the orchestrator during trigger matching, because the orchestrator only holds the lock file and never loads the workflow code the rule functions live in. A job-level rule runs inside the execution sandbox after the agent is spawned, the repository source is restored, and the workflow module is loaded, so a job that a rule skips still pays the cost of spawning an agent and restoring the repository source before the skip is decided; only the job's steps are avoided. See [how your workflow code executes](https://docs.kici.dev/user/execution-model/) for where this sits among the three phases. ## Hook failure behavior If a hook throws an error or times out: - The job status changes to `failed` with a compound reason (e.g., "cancelled (onCancel hook failed: Connection timeout)") - Remaining hooks for that level are skipped - The failure is visible in the dashboard as a failed hook step - Force cancel kills running hooks immediately via SIGKILL This behavior is consistent across all hook types. --- _Source: `packages/sdk/src/hooks/`, `packages/sdk/src/types.ts`_ --- ## Lock file and workflow drift Source: https://docs.kici.dev/user/lock-file-and-drift/ KiCI uses a **two-artifact model**: TypeScript workflows are the source of truth; the lock file (`kici.lock.json`) is the execution contract. The orchestrator reads only the lock file to match triggers and decide cache vs build. Keeping these in sync is important. ## Why the lock file matters - **Orchestrator** fetches the lock file at the commit SHA and uses it to evaluate triggers and to look up the cached `.kici/` source tarball + `node_modules` tarball. It never runs your TypeScript. - **Agents** download the cached source tarball (or, on cold cache, the build agent clones + packs it), register the shared TypeScript loader hook, and dynamic-`import()` the workflow `.ts` directly. The lock file's per-workflow `contentHash` identifies the expected contents of the whole `.kici/` directory and is verified against the extracted source before any step runs. The tarball's own bytes are verified against the digest the orchestrator dispatched, and the restored tree **replaces** `.kici/` rather than being unpacked over it, so a file you deleted does not survive a cache hit. If you change a workflow file (`.ts`) but do **not** regenerate and commit the lock file, the repo at that commit has **drift**: the lock file no longer matches the source. Triggers and cache keys can be wrong, and runs can fail with a clear “stale lock file” error once the agent verifies the hash. ## Lock file structure The lock file (`kici.lock.json`) is a JSON file with the following top-level fields: | Field | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `schemaVersion` | Lock file schema version, stamped by the compiler that produced the lock. Incremented on every format change. The orchestrator accepts a range of versions — see [schema compatibility window](https://docs.kici.dev/user/lock-file-and-drift/#schema-compatibility-window) — rather than requiring an exact match. | | `minReaderVersion` | The oldest orchestrator schema version that can read this lock (the newest breaking version at compile time). An orchestrator whose own schema is below this rejects the lock and asks you to upgrade it. Omitted on locks compiled before the compatibility window existed. See [schema compatibility window](https://docs.kici.dev/user/lock-file-and-drift/#schema-compatibility-window). | | `source` | Reference to the source file and export (e.g., `{ file: '.kici/workflows/ci.ts', export: '#default' }`). | | `contentHash` | SHA-256 of the serialized lock file content (excluding itself). Changes when any workflow, trigger, or job changes. | | `lockfileHash` | SHA-256 of the detected package manager's lockfile, used as the dependency cache key. The lockfile is `.kici/package-lock.json` for npm, or the repo-root `pnpm-lock.yaml` / `yarn.lock` for a pnpm/yarn workspace; the hash input is prefixed with the manager name so a manager change is a guaranteed cache miss. Omitted when no lockfile exists. | | `siblingsDigest` | SHA-256 over the git-tracked source of every in-repo `workspace:` / `file:` / `link:` / `portal:` sibling package `.kici` depends on, transitively. Part of the dependency cache key alongside `lockfileHash`, because editing a sibling's source moves no package manager lockfile. Omitted when `.kici` depends on no in-repo package, which is the common case. | | `workflows` | Array of workflow entries, each with its own `contentHash`, `compileSchemaVersion`, triggers, and jobs. | Each workflow entry includes: | Field | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Workflow name. | | `source` | Per-workflow source file and export reference. | | `contentHash` | SHA-256 of a digest over the whole `.kici/` directory mixed with `compileSchemaVersion` (and an `assetDigest` of declared `hashFiles` when present): `SHA-256(compileSchemaVersion + ":" + treeDigest [+ "\0" + assetDigest])`. The tree digest covers every file under `.kici/` except the paths declared in `.kici/.kiciignore` — see [files the content hash skips](https://docs.kici.dev/user/lock-file-and-drift/#files-the-content-hash-skips-kicikiciignore). Paths are sorted and line endings normalized. The orchestrator uses this as the source-tarball cache key and the agent re-computes it against the extracted tree to detect drift. | | `compileSchemaVersion` | Compiler schema version used when computing `contentHash` (currently `7`). The hash input is line-ending-normalized (CRLF → LF) so a lock file produced on Linux matches the agent's hash on Windows where Git's `core.autocrlf=true` rewrites checked-out text to CRLF. Bumping the schema version invalidates every existing source cache entry even if source is unchanged, which is the correct behavior when the compile-time or runtime contract changes. | | `triggers` | Trigger definitions extracted from the workflow (used by the orchestrator for event matching). | | `jobs` | Job definitions with scheduling metadata (runsOn, needs, matrix, contexts, concurrency, container, checkout, gracePeriod, label routing, dynamic fields, etc.). | | `rules` | Workflow-level conditional rules (optional). Stored as dynamic references since rule functions cannot be serialized. | | `description` | Optional workflow description. | | `hashFiles` | Declared glob patterns for extra files included in the content hash (optional). See [extra files in the content hash](https://docs.kici.dev/user/lock-file-and-drift/#extra-files-in-the-content-hash-hashfiles). | | `resolvedHashFiles` | Resolved file paths from `hashFiles` at compile time (optional). Recorded so the agent can verify without re-discovering. | | `contexts` | Secret contexts declared by the workflow (optional). The orchestrator validates access to each context before dispatch. | | `registries` | Private npm registry declarations the agent authenticates against before install (optional): `url`, `scope`, `tokenSecret` reference, `alwaysAuth`. Resolved token bytes never appear in the lock file. See [private registries](https://docs.kici.dev/user/private-registries/). | | `installEnv` | Extra qualified secret refs (`:`) projected as env vars on the install subprocess for use with a committed `.kici/.npmrc` (optional). See [private registries](https://docs.kici.dev/user/private-registries/). | | `concurrency` | Workflow-level concurrency config: `hasGroup`, `cancelInProgress`, `max` (optional). See [concurrency groups](https://docs.kici.dev/user/concurrency/). | | `timeout` | Whole-run wall-clock timeout in milliseconds (optional). The orchestrator reads this at run creation to set the run deadline. | | `approval` | Normalized approval gate (optional): `clauses`, `reason`, `timeoutSeconds`, `when`. When present the whole run is held before any job is dispatched. Job and step entries carry the same normalized block for job- and step-level gates. See [approval gates](https://docs.kici.dev/user/approvals/). | | `hasFilter` | `true` when the workflow declares a workflow-level `filter` predicate (optional; omitted rather than `false`). The predicate itself is never serialized — the flag tells the orchestrator an agent must evaluate the workflow before any of its jobs is dispatched. See [global workflows](https://docs.kici.dev/user/global-workflows/#narrowing-with-a-filter). | | Hook flags | Boolean flags (`hasOnCancel`, `hasCleanup`, `hasOnSuccess`, `hasOnFailure`) indicating which lifecycle hooks are defined. Job entries additionally have `hasBeforeStep` and `hasAfterStep`. | Step entries carry their own capability flags, so the orchestrator can reason about a step without loading your TypeScript: | Flag | Meaning | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `hasOutputs` | The step declares an output schema, so the run has typed outputs to record for it. Present on every step entry. | | `hasCheck` | The step declares an idempotent `check` facet, so a run can be dispatched in check mode. See [idempotent steps and check mode](https://docs.kici.dev/user/idempotent-steps/). | | `hasWhenInSync` | The step declares a `whenInSync` facet that produces its outputs when `check` reports no drift. | | `hasRules` | The step has conditional rules (evaluated agent-side). | | `hasOnCancel` | The step has an `onCancel` hook. | | `hasCleanup` | The step has a `cleanup` hook. | The check, apply, and `whenInSync` closures themselves are never serialized — only the flags are. The agent re-evaluates the real workflow TypeScript. ## Schema compatibility window The orchestrator does not require the lock's `schemaVersion` to exactly match its own. Instead it accepts a **compatibility window**, so an additive SDK update no longer forces every orchestrator sharing a fleet to upgrade in lockstep. A lock is accepted when **both** hold: - Its `schemaVersion` is at or above the orchestrator's oldest supported version. Most schema bumps are additive — they add fields older readers simply ignore — so a lock compiled by a newer SDK still loads on an older orchestrator. - The orchestrator's own schema version is at or above the lock's `minReaderVersion`. This guards the one case a version floor alone cannot detect: a lock that relies on a **breaking** change the orchestrator predates. Two out-of-window cases are rejected with an actionable error (recorded as a `lockfile_corrupt` delivery, never a silent mis-route): - **Lock too old** — compiled by an SDK predating a breaking schema change the orchestrator relies on. Fix: recompile with a current SDK (`kici compile`) and push the refreshed lock. - **Lock too new (breaking)** — requires an orchestrator newer than the one reading it. Fix: upgrade the orchestrator to the version the error names. When you `kici compile` at a schema version that is itself a breaking version, the CLI prints a one-line notice that orchestrators older than that version cannot read the emitted lock. It is informational only — the orchestrator is the authoritative check. ## Rule: commit both together **Always commit `.kici/kici.lock.json` in the same commit as the workflow source files it was generated from.** 1. After editing `.kici/workflows/*.ts`, run: ```bash npx kici compile ``` 2. Stage both the workflow file(s) and `.kici/kici.lock.json`. 3. Commit them together. That way the lock file at every commit SHA matches the workflow source at that SHA. ## Catch drift early: pre-commit and CI Use automation so drift is caught before it reaches the repo. ### Pre-commit hook Install a hook that compiles and stages the lock file before each commit: ```bash npx kici hook install ``` This runs `kici compile && git add .kici/kici.lock.json` before each commit: if compilation fails the commit is blocked; if it succeeds the updated lock file is automatically staged. See [CLI Reference — kici hook](https://docs.kici.dev/user/cli/authoring-and-local/#kici-hook) for options (husky, lefthook, pre-commit, prek, raw git). ### CI check In your CI pipeline, verify that the workflow source compiles without errors: ```bash kici compile --check ``` This validates all workflows and generates the lock file in memory without writing it. If any workflow has syntax errors or invalid configuration, the command exits non-zero. Pair this with the agent-side hash verification (below) for full drift detection -- `--check` catches broken source, while the agent catches source-lock-file mismatches at run time. ## Files the content hash skips (`.kici/.kiciignore`) The per-workflow content hash covers everything under `.kici/` except the paths declared in `.kici/.kiciignore`. `kici init` writes that file for you with this default set: ``` node_modules/ types/ .npmrc package-lock.json pnpm-lock.yaml kici.lock.json ``` Every entry except `kici.lock.json` names something KiCI itself regenerates. The agent installs your workflow's dependencies before it re-checks the hash, and that install rewrites `package-lock.json`, `pnpm-lock.yaml`, `.npmrc` and `node_modules/`; `kici compile` refreshes `types/` after it has already hashed the tree. Hashing any of them would make the hash change on every run, and the drift gate would reject work that never changed. `kici.lock.json` is different: the hash is written **into** that file, so hashing it would make it an input to itself. It stays excluded whatever your `.kiciignore` says. Patterns are gitignore-style and are matched relative to `.kici/`. A trailing `/` matches a directory and everything beneath it, a bare name matches at any depth, and a pattern containing a slash is anchored at `.kici/`. One rule differs from `git`: **a symlink to a directory counts as a directory**. So `node_modules/` covers a `.kici/node_modules` that is a symlink into a shared dependency tree, where `git` would treat that link as a file. The exclusion means "skip the dependency tree, whatever shape it takes on disk". Hashing the link instead produced a hash your build agent could not reproduce, because its own dependency install always writes a real directory there. A symlink the exclusions do **not** cover is still hashed — as its link target, not as the bytes behind it. The source tarball has to carry that link unchanged for the agent to agree. So `kici compile` warns about a link it cannot carry: one whose target is absolute (extraction strips the leading `/`), or whose target points outside `.kici/`'s parent (extraction drops the link). Point the link inside `.kici/`, replace it with the files it names, or list it in `.kiciignore`. :::caution[The file replaces the defaults — it does not add to them] When `.kici/.kiciignore` exists, it **is** the exclusion list. A one-line file excludes one path and re-includes everything else, `package-lock.json` included. `kici compile` warns when your file omits a path a run rewrites, and names both the path and the instability it causes. Delete the file to fall back to the defaults. ::: `.kiciignore` is itself covered by the hash. Which files define a workflow's identity is part of that identity, so editing the file forces a recompile — and nobody can change what a lock file attests to without changing the lock file. > **Not the repo-root `.kiciignore`.** A `.kiciignore` at the root of your repository is a separate, unrelated file: it selects which working-tree files `kici run remote` uploads. Only the one inside `.kici/` affects the content hash. ## Extra files in the content hash (`hashFiles`) A helper the workflow imports from `.kici/lib/` is already covered, so editing it invalidates the cache on its own. If your workflow depends on files **outside** `.kici/` -- configuration files, scripts, Dockerfiles, etc. -- changes to those files will **not** invalidate the cache unless you declare them. Use the `hashFiles` option on a workflow to include additional paths or glob patterns (relative to the repo root) in the content hash: ```typescript export default workflow('deploy', { hashFiles: ['config.json', 'scripts/*.sh'], jobs: [/* ... */], }); ``` When any of the matched files change, the content hash formula becomes `SHA-256(compileSchemaVersion + ":" + treeDigest + "\0" + assetDigest)` where `assetDigest` is a deterministic encoding of the resolved file paths and their contents. This busts the source-tarball cache and forces the build agent to pack and upload a fresh tarball. The resolved file paths are recorded in the lock file under `resolvedHashFiles` so the agent can verify without re-discovering the workflow. ## Agent-side safety net If drift still occurs (e.g. someone committed only the `.ts` change), the agent detects it at run time before any step runs: - After extracting the `.kici/` source tarball (or loading source from a `git clone` on the build path), the agent walks the whole extracted `.kici/` tree and re-computes `contentHash = SHA-256(compileSchemaVersion + ":" + treeDigest [+ "\0" + assetDigest])` using the same implementation as the compiler. Because it covers the tree, an edit to any file the workflow imports is caught, not just an edit to the entry file. - If the orchestrator sent a `contentHash` (from the lock file) and the computed hash does **not** match, the agent fails the run with an error like: **lock file is out of date** (workflow source changed without regenerating the lock file). The error includes the baked agent `@kici-dev/sdk` version + bundle hash so operators can debug cross-host compile mismatches. When the hashed tree carries symlinks, the error names them too — recompiling cannot reconcile a link the tarball omits or extraction rewrites, so the usual remedy would loop. So even without a pre-commit or CI check, a stale lock file will cause the run to fail with a clear message instead of running with the wrong workflow. ## Summary | Goal | What to do | | ---------------------------- | ------------------------------------------------------------------------------------------ | | Keep lock file in sync | Commit `kici.lock.json` with the workflow `.ts` changes; run `kici compile` before commit. | | Catch drift before commit | Install a pre-commit hook with `kici hook install`. | | Catch broken source in CI | Run `kici compile --check` in CI. | | Bust cache on external files | Add `hashFiles: ['config.json']` to include non-workflow files in the content hash. | | Skip a path inside `.kici/` | List it in `.kici/.kiciignore` — remember the file replaces the defaults. | | Fail fast when drift remains | Rely on the agent’s hash verification when it compiles from source. | ## See also - [Getting started](https://docs.kici.dev/user/getting-started/) — compile and commit the lock file - [CLI reference](https://docs.kici.dev/user/cli-reference/) — `kici compile`, `kici compile --check`, `kici hook` - [Architecture — Data flows](https://docs.kici.dev/architecture/data-flows/) — how the lock file is used in the pipeline --- ## Testing guide Source: https://docs.kici.dev/user/testing-guide/ Test your workflows remotely against the full CI pipeline from your local machine. `kici run remote` uploads your current repo state (including uncommitted changes), triggers the pipeline, and streams execution logs back in real time. ## Overview KiCI gives you four ways to exercise a workflow before it runs in real CI, ordered from fastest / least faithful to slowest / most faithful: - **`kici preview `** — a pure dry-run that shows _which_ workflows and jobs a trigger event would match. No steps execute. Use it to check trigger and rule logic. See [`kici preview`](https://docs.kici.dev/user/cli/authoring-and-local/#kici-preview). - **Unit-test a step function** — call your step's function directly in vitest with a fabricated context from `@kici-dev/sdk/testing`. No orchestrator, no agent — just your step logic. See [Unit-testing step functions](https://docs.kici.dev/user/testing-guide/#unit-testing-step-functions) below. - **`kici run --local`** — executes the whole workflow on your own machine, which becomes an ephemeral agent. A faithful end-to-end run without a remote round-trip. See [Local execution as an alternative](https://docs.kici.dev/user/testing-guide/#local-execution-as-an-alternative). - **`kici run remote`** — uploads your working tree and runs the full pipeline (orchestrator + agent) remotely, streaming logs back. The most faithful check. The rest of this guide covers `kici run remote`. `kici run remote` connects your local development environment to the remote orchestrator/agent pipeline. Instead of pushing a commit and waiting for CI, you can: - Run any workflow against your current working tree (including unstaged changes) - Get real-time log output streamed back to your terminal - Give test runs test-scoped secrets — your local secret files and `--env` values (uploaded encrypted) plus any environment flagged `allowLocalExecution: true` — while production environments stay unreachable - Detect test mode in workflow code via `ctx.isTestRun` The command is remote-only -- all execution happens on the orchestrator and agent. For local-only trigger matching previews, use `kici preview `. :::note[Orchestrator prerequisite: cache storage] `kici run remote` uploads your working-tree overlay to the orchestrator's **cache storage** via a pre-signed URL, and the agent fetches it from there (see [Repo state transfer](https://docs.kici.dev/user/testing-guide/#repo-state-transfer)). The target orchestrator must therefore have cache storage enabled (`KICI_STORAGE_TYPE` = `s3` or `filesystem`). - **Both quickstarts wire this up for you** — the [Docker / Podman quickstart](https://docs.kici.dev/user/quickstart/compose/) and the [bare-metal quickstart](https://docs.kici.dev/user/quickstart/bare-metal/) each ship a SeaweedFS object store and pre-fill the orchestrator's `KICI_STORAGE_*` block, so `kici run remote` works out of the box (see each guide's "run a workflow without pushing" step). - **A hand-rolled orchestrator deploy does not configure storage by default** — enable a backend before using `kici run remote`: - **`filesystem`** — simplest for a single-host orchestrator: set `KICI_STORAGE_TYPE=filesystem` and `KICI_STORAGE_FS_PATH=/var/lib/kici/cache`. No external service needed; blobs are served through the orchestrator's own HMAC-signed HTTP route. - **`s3`** — any S3-compatible bucket. **A non-public / self-hosted endpoint works**: set `KICI_STORAGE_TYPE=s3`, `KICI_STORAGE_BUCKET`, `KICI_STORAGE_ENDPOINT=https://your-endpoint` and (for most self-hosted services) `KICI_STORAGE_FORCE_PATH_STYLE=true`. If the developer machine running `kici run remote` reaches the bucket at a different address than the orchestrator, set `KICI_STORAGE_UPLOAD_ENDPOINT` to the developer-reachable address; if agents reach it at yet another address (e.g. agents in containers), set `KICI_STORAGE_EXTERNAL_ENDPOINT` to the agent-routable URL. See [Storage layout](https://docs.kici.dev/operator/orchestrator/storage-layout/) for the full env-var reference. ::: ## Unit-testing step functions A KiCI step is just a TypeScript function that takes a context and does work — so you can test it in isolation with vitest, no orchestrator or agent required. `@kici-dev/sdk/testing` builds a real step context for you: `ctx.$` runs real shell commands, `ctx.secrets` resolves values you seed, and `ctx.emit(...)` is recorded so you can assert on it. Extract your step body into a named function so both your workflow and your test can call it: ```typescript // steps/deploy.ts import type { StepContext } from '@kici-dev/sdk'; export async function deployStep(ctx: StepContext): Promise { const branch = (await ctx.$`git rev-parse --abbrev-ref HEAD`).stdout.trim(); const token = await ctx.secrets.get('DEPLOY_TOKEN'); ctx.setEnv('DEPLOYED_BRANCH', branch); await ctx.emit('deploy-complete', { branch }); ctx.log.info(`deployed ${branch} with a ${token.length}-char token`); } ``` ```typescript // steps/deploy.test.ts import { describe, it, expect, afterEach } from 'vitest'; import { createTestStepContext, type TestStepContext } from '@kici-dev/sdk/testing'; import { deployStep } from './deploy.js'; describe('deployStep', () => { let harness: TestStepContext | undefined; afterEach(async () => { await harness?.dispose(); harness = undefined; }); it('records the deploy event and sets the output env var', async () => { harness = createTestStepContext({ repoRoot: process.cwd(), secrets: { flat: { DEPLOY_TOKEN: 'test-token' } }, }); await deployStep(harness.ctx); expect(harness.ctx.env.DEPLOYED_BRANCH).toBeDefined(); expect(harness.emitCalls).toHaveLength(1); expect(harness.emitCalls[0].eventName).toBe('deploy-complete'); }); }); ``` `createTestStepContext(options?)` takes zero required arguments. Common options: | Option | Purpose | | ---------- | ------------------------------------------------------------------------------------- | | `repoRoot` | Directory `ctx.$` runs in. Defaults to the current working directory. | | `secrets` | Seed `ctx.secrets`: `{ flat: { KEY: 'value' } }`, or `{ contexts: { prod: { … } } }`. | | `inputs` | Typed `ctx.inputs` from upstream `needs`. | | `matrix` | `ctx.matrix` values for a matrix job instance. | | `$` | Inject a fake shell for pure-logic tests that must not actually run commands. | | `log` | Replace the default console logger (e.g. a spy). | Every other `StepContext` member (`cache`, `kici`, `artifacts`, `attestProvenance`, …) has a safe default and can be overridden the same way. The returned handle exposes `ctx` (pass to your step), `emitCalls` (assert emitted events), and `dispose()` (call in `afterEach` to clean up seeded secret state and restore any `process.env` variables the step set via `setEnv` / `addPath`). Orchestrator-backed APIs — `ctx.kici.*`, `ctx.artifacts.*`, `ctx.attestProvenance(...)` — reject by default (there is no orchestrator in a unit test); override them with a stub if your step calls them. ## Getting started ### 1. Authenticate ```bash kici login ``` This opens your browser for OAuth authentication and stores a personal access token in `~/.kici/config`. For CI/CD pipelines or headless environments, use `kici login --token ` or `kici login --device` instead. See [CLI authentication](https://docs.kici.dev/user/cli-auth/) for details. ### 2. Write a test fixture Fixtures define the events you want to simulate. They live in `.kici/tests/*.ts` and use the same SDK trigger functions as workflows. ```typescript // .kici/tests/push-tests.ts import { fixture, push } from '@kici-dev/sdk'; export const pushMain = fixture('push-main', { event: push({ branches: ['main'] }), }); export const pushDevelop = fixture('push-develop', { event: push({ branches: ['develop'] }), }); ``` Each file can export multiple fixtures. The `fixture()` factory takes an ID (used on the command line) and options including the event to simulate. ### 3. Run a fixture ```bash # List available fixtures kici run remote # Run a specific fixture kici run remote push-main # Run all fixtures matching a glob kici run remote 'push-*' # Run everything kici run remote --all ``` The single quotes keep your shell from expanding `push-*` against local files, so the pattern reaches KiCI intact for its own fixture-glob matching. ## Fixture reference ### Event types Fixtures accept any SDK trigger function as their event: ```typescript import { fixture, push, pr, comment, tag, release } from '@kici-dev/sdk'; // Push event export const pushMain = fixture('push-main', { event: push({ branches: ['main'] }), }); // PR event export const prOpen = fixture('pr-open', { event: pr({ branches: ['main'], actions: ['opened'] }), }); // Comment event export const prComment = fixture('pr-comment', { event: comment({ actions: ['created'] }), }); // Tag event export const tagRelease = fixture('tag-release', { event: tag({ tags: ['v*'] }), }); // Release event export const published = fixture('release-published', { event: release({ actions: ['published'] }), }); ``` ### Overrides Override default payload values per fixture: ```typescript export const pushFeature = fixture('push-feature', { event: push({ branches: ['feature/*'] }), branch: 'feature/auth', // Override branch name sha: 'abc123def456', // Override commit SHA repo: 'myorg/myrepo', // Override repository pr: 42, // Override PR number (for PR events) }); ``` When not specified, these default to values detected from your local git repo (current branch, HEAD SHA, remote URL). ### Secret context mappings Map secret contexts to your fixture: ```typescript export const pushWithSecrets = fixture('push-with-secrets', { event: push({ branches: ['main'] }), secrets: { db: 'test-database', api: 'test-api-keys', }, }); ``` This maps the `db` secret context to the `test-database` context, and `api` to `test-api-keys`. This mapping is honored by **both** `kici run --local` and `kici run remote`: - For a local **`kici run --local`** (see [`kici run --local`](https://docs.kici.dev/user/cli/runs-and-approvals/#kici-run-event---local)), each named context is resolved from your local secret files (`.kici/.secrets`, `.env.local`, `secrets.yaml`, and `--env` flags). - For **`kici run remote`**, each named context maps to an orchestrator **context**, and the orchestrator resolves that context's secrets for the run. The target context must be flagged `allowLocalExecution: true` — mapping a context to a missing or non-test context rejects the run (see [Secret contexts for testing](https://docs.kici.dev/user/testing-guide/#secret-contexts-for-testing) below). **A fixture `secrets:` mapping is fail-closed; a job's bound `context:` is not.** The reject above applies only to the fixture `secrets:` mapping — an explicit request for that context's secrets. A job's own bound `context:` (`job('deploy', { context: 'production', … })`) is treated differently on a test run: if it resolves to a non-test or unconfigured context it is **skipped with a warning**, not rejected, so a job that deploys to production in real runs stays locally testable for its non-secret logic. `kici run remote` prints a warning naming the skipped context(s), and the dashboard run view shows the same notice. See [Skip-on-test](https://docs.kici.dev/user/contexts/#multiple-contexts-per-job) in the contexts guide. ### Async fixtures For dynamic fixture configuration, export an async function: ```typescript export const dynamicFixture = fixture('dynamic', async () => ({ event: push({ branches: ['main'] }), sha: await getCurrentSha(), })); ``` ## Running tests ### Basic commands ```bash # List all available fixtures (discovers .kici/tests/*.ts) kici run remote # Run a single fixture by ID kici run remote push-main # Glob matching -- run all push-related fixtures kici run remote 'push-*' # Run all fixtures sequentially kici run remote --all # Run all fixtures in parallel kici run remote --all --parallel ``` ### Direct workflow run Bypass trigger matching and run a specific workflow directly: ```bash kici run remote --workflow ci ``` This skips the trigger evaluation step and runs all jobs in the named workflow. ### Output modes ```bash # Default: full log streaming with colored job prefixes kici run remote push-main # Quiet: minimal output (just pass/fail result) kici run remote push-main --quiet # JSON: machine-readable structured output kici run remote push-main --json # JUnit XML: for CI integration kici run remote push-main --junit results.xml ``` ### Non-blocking execution ```bash # Fire and forget -- returns immediately with run ID kici run remote push-main --no-wait # Check status later kici runs show ``` ### Cancellation Press Ctrl+C during a running test to send a cancel signal to the orchestrator. The agent job will be terminated gracefully. ## Repo state transfer When you run `kici run remote`, the CLI: 1. Detects all files differing from HEAD (staged, unstaged, and untracked) 2. Creates a compressed tarball of changed files 3. Encrypts the tarball using X25519 ECDH key exchange 4. Uploads the encrypted tarball to storage via a signed URL 5. Triggers the pipeline with a reference to the upload The agent clones your repo at HEAD, then applies the overlay tarball on top -- giving you the exact same file state as your local working tree. ### What gets included - Modified tracked files (staged and unstaged) - New untracked files (not in `.gitignore`) - File deletions (tracked files you deleted locally) ### What gets excluded - Files matching `.gitignore` patterns - Files matching `.kiciignore` patterns (additional exclusions) - The `.git` directory itself ### `.kiciignore` Create a `.kiciignore` file in your repo root to exclude additional files from the upload: ``` # Large binaries *.bin *.iso data/fixtures/large-dataset.csv # Local-only configs .env.local docker-compose.override.yml ``` The format is the same as `.gitignore` -- one glob pattern per line, `#` for comments. ### Size limits | Threshold | Behavior | | --------- | --------------------------------------------------------------------------------- | | < 50 MB | Normal upload | | 50-500 MB | Warning displayed, upload proceeds | | > 500 MB | Error -- reduce bundle size via `.kiciignore` or check for unintended large files | The CLI always shows a pre-upload summary before transferring: ``` 12 files changed, 3 new, 1 deleted (2.3 MB compressed) ``` ## Secret contexts for testing The goal of the test-secret model is to let test runs reach **test-only credentials** while keeping production credentials out of reach. `kici run remote` combines two sources of secrets for a test run, then merges them with a clear precedence and a fail-closed gate. ### CLI-uploaded local secrets `kici run remote` collects the same local secret values that `kici run --local` reads — `.kici/.secrets`, `.kici/.env.local`, `.kici/secrets.yaml`, and any `--env KEY=VALUE` flags — and uploads them **encrypted** to the orchestrator alongside the run. The orchestrator decrypts them only to inject them into the agent for that run; the control plane never sees the values. ```bash # Provide an ad-hoc test value for a single remote run kici run remote push-main --env KICI_DATABASE_URL=postgresql://localhost/test ``` `--env` provides a **flat** per-run override; `--context .=` is its sibling for a **namespaced** per-run override, placing the value under the named context `ctx`. Both are uploaded **encrypted** and follow the same precedence rule below — a CLI-supplied value wins over the orchestrator test-context secret on a key collision. ```bash # Provide a namespaced per-run value under the 'db' context kici run remote push-db --context db.KICI_DATABASE_URL=postgresql://localhost/test ``` Because these values originate on your machine, they are the natural place to put throwaway test credentials without touching any orchestrator-stored secret. ### Orchestrator test-context secrets In addition to your uploaded values, the orchestrator resolves test-scoped secrets from its own store for a remote test run: - The job's own declared `context` contributes its resolved secrets (flat). Static strings and **pure dynamic functions** both participate: a pure `context:` function (see [Dynamic values](https://docs.kici.dev/user/dynamic-values/)) is evaluated against the fixture's simulated event, and the resolved name is gated and resolved like a static one. Impure dynamic functions (those requiring an init job) are not evaluated for test runs — use a fixture `secrets:` mapping (or `--context`) to supply such a job's secrets. - Each fixture `secrets: { ctx: envName }` mapping resolves the named context's secrets under the namespaced context `ctx`. Both paths are restricted to contexts flagged `allowLocalExecution: true`. A production context left at the default `false` is never resolvable for a test run. ```typescript export const pushWithDb = fixture('push-db', { event: push({ branches: ['main'] }), secrets: { db: 'test-database' }, // 'test-database' must be allowLocalExecution: true }); ``` ```typescript step('migrate', async (ctx) => { const dbUrl = await ctx.secrets.get('KICI_DATABASE_URL'); await ctx.$`npx prisma migrate deploy`; }); ``` ### Precedence: CLI values win When a key exists in both sources, the **CLI-uploaded local value wins** over the orchestrator test-context value. This makes a local override a per-run knob: set `--env KICI_DATABASE_URL=...` (or put it in `.kici/.secrets`) to shadow the test context's value for just that run, without changing anything on the orchestrator. ### Fail-closed on non-test contexts Test-run secret resolution is fail-closed: - If a fixture maps to a context that does not exist, the run is **rejected**. - If a fixture maps to a context whose `allowLocalExecution` is `false`, the run is **rejected**. - The `allowLocalExecution` gate applies to **all** remote test runs: a run whose matched workflow targets a context with the flag off is rejected, so a test run can never resolve production secrets. ### The `allowLocalExecution` context flag Each context carries an `allowLocalExecution` flag (default `false`) that controls test-run access to that context and to its secrets. Production contexts should leave it at `false`; create a dedicated test context with `allowLocalExecution: true` that binds only test-only secret scopes for the jobs you want test runs to use. The flag is set by the orchestrator operator, either via the CLI: ```bash kici-admin context set-policy --env test-database --allow-local-execution true ``` or via the dashboard's "Test runs" toggle on the context detail page. `kici secrets list` only surfaces contexts whose `allowLocalExecution` is `true`, so production contexts are never advertised as test-accessible. ### Local execution as an alternative `kici run --local` resolves the same local secret files entirely on your machine and honors the fixture `secrets: { ... }` mapping to pick which local context backs each name (see [`kici run --local`](https://docs.kici.dev/user/cli/runs-and-approvals/#kici-run-event---local)). Because the values never leave your machine, it's a good fit when you want to exercise secret-dependent steps without involving the orchestrator at all. ### Discovering available contexts ```bash # List test-accessible secret contexts and their key names (not values) kici secrets list ``` ## Detecting test mode in workflows Use `ctx.isTestRun` to conditionally skip destructive operations: ```typescript step('deploy', async (ctx) => { if (ctx.isTestRun) { ctx.log.info('Skipping deployment in test mode'); return; } await ctx.$`kubectl apply -f k8s/`; }); ``` ## Run history ### Viewing history ```bash # Show recent test runs (from local history) kici run remote --history ``` ### Run details ```bash # Show run summary (reads the Platform, falls back to local history) kici runs show # Show full logs kici runs logs # Show logs for a specific job kici runs logs --job build # Machine-readable output kici runs show --json ``` ## Scaffolding with kici init Running `kici init` in a new project scaffolds a sample test fixture alongside the workflow templates: ``` .kici/ workflows/ hello-world.ts # Sample workflow pr-checks.ts # Sample PR workflow tests/ push-test.ts # Sample push fixture package.json tsconfig.json .kiciignore # Default exclusion patterns ``` The generated fixture uses the detected default branch: ```typescript // .kici/tests/push-test.ts import { fixture, push } from '@kici-dev/sdk'; export const pushMain = fixture('push-main', { event: push({ branches: ['main'] }), }); ``` ## See also - [CLI reference](https://docs.kici.dev/user/cli-reference/) -- complete command reference for all `kici` commands - [SDK reference](https://docs.kici.dev/user/sdk-reference/) -- trigger functions, step context, and workflow API - [Workflow patterns](https://docs.kici.dev/user/workflow-patterns/) -- example workflows to test against --- ## Workflow patterns Source: https://docs.kici.dev/user/workflow-patterns/ Practical patterns for building real-world KiCI workflows in TypeScript. The patterns are organised across seven pages -- start with [Basic CI](https://docs.kici.dev/user/patterns/basic/) if you're new, or jump to [Integrations](https://docs.kici.dev/user/patterns/integrations/) if you're wiring up a non-GitHub forge or a generic webhook. | Page | Covers | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | [Basic CI](https://docs.kici.dev/user/patterns/basic/) | Single-job CI, PR-only / push-only filters, multiple triggers on one workflow, manual-only workflows. | | [Conditionals & matrix](https://docs.kici.dev/user/patterns/conditionals-matrix/) | Conditional execution with rules, matrix builds (static + dynamic), and dynamic job generation. | | [Integrations](https://docs.kici.dev/user/patterns/integrations/) | Workflow chaining, generic webhooks, Stripe handlers, self-hosted git forges (Forgejo / Gitea / Gogs), plain GitHub repo webhooks (no GitHub App). | | [Scheduling & events](https://docs.kici.dev/user/patterns/scheduling-and-events/) | Nightly cron, workflow-complete-triggered deploys, custom event chaining. | | [Git credentials](https://docs.kici.dev/user/patterns/git-credentials/) | Declaring named git credentials from the secrets backend, and pushing from a job (including to the job's own repository). | | [Host restart](https://docs.kici.dev/user/patterns/host-restart/) | `restartHost()` and `waitForHostAlive()` -- reboot the host a workflow runs on and resume in a second job pinned to the same host. | | [Pattern reference](https://docs.kici.dev/user/patterns/reference/) | Step context, the examples repository, and GitHub check run output -- cross-cutting reference shared by every pattern above. | ## See also - [Event system](https://docs.kici.dev/user/events/) -- event model concepts, registration model, circuit breaker - [SDK reference](https://docs.kici.dev/user/sdk-reference/) -- complete API reference for all functions used in these patterns - [CLI reference](https://docs.kici.dev/user/cli-reference/) -- how to compile and test these workflows locally - [Getting started](https://docs.kici.dev/user/getting-started/) -- installation and first workflow setup - [Job execution lifecycle](https://docs.kici.dev/architecture/execution/job-execution/) -- how agents execute the jobs defined in these patterns - [GitHub checks architecture](https://docs.kici.dev/architecture/webhooks/github-checks/) -- deep dive into the check run system --- ## kici: authoring & local dev Source: https://docs.kici.dev/user/cli/authoring-and-local/ ## Guide ### kici compile Compile workflows from `.kici/workflows/` to `kici.lock.json`. ```bash kici compile [options] ``` **Examples:** ```bash # Compile all workflows kici compile # Validate and type-check (CI-friendly, no file writes) kici compile --check # Watch mode for development kici compile --watch # Custom .kici directory location kici compile --kici-dir packages/app/.kici # Verbose output for debugging kici compile --verbose ``` **Exit codes:** | Code | Meaning | | ---- | --------------------------- | | 0 | Compilation successful | | 1 | Compilation failed (errors) | The `--check` flag is useful in CI pipelines and pre-commit hooks. It validates that workflows are syntactically and semantically correct **and** runs a `tsc --noEmit` type-check over `.kici/workflows/**`, so type-broken workflows are caught at compile time instead of shipping silently. No lock file or other files are written. The type-check requires `.kici/tsconfig.json` and a `typescript` dependency — both are scaffolded by `kici init`. In a JavaScript-only workspace (`kici init --mjs`, which has no `tsconfig.json`), the type-check is skipped with a notice and validation still runs. When the type-check finds errors, `kici compile --check` prints each one in `file:line:column error [E120]: message` form and exits non-zero. Compile and validation errors carry the real `file:line:column` of the offending job or step (anchored to the job's first step location), so you can jump straight to the source instead of a generic line 1. **Auto-type regeneration:** When authenticated (via `kici login`), `kici compile` automatically refreshes `.kici/types/secrets.d.ts` after each successful compilation. This keeps type declarations in sync with your orchestrator's secret contexts. The type regeneration is non-blocking -- if the orchestrator is unreachable, compilation still succeeds with a warning. The `--check` flag skips type regeneration since no files are written. ### kici preview Preview which workflows match a trigger event (dry-run, no execution). Useful for verifying trigger configurations during development. ```bash kici preview [event] [options] ``` **Examples:** ```bash # Preview which workflows match a push event kici preview push # Preview PR trigger matching kici preview pr:open # Preview with branch override kici preview push --branch develop # Filter to specific workflow kici preview push --workflow ci # Simulate changed files for path-filtered triggers kici preview push --files src/index.ts --files README.md ``` **Exit codes:** | Code | Meaning | | ---- | ------------------------------------------ | | 0 | Preview completed (including zero matches) | | 1 | Error | **Migration from the old `test` command:** The dry-run preview command was renamed from `test` to `preview`. If you were using the old `test` command with a fixture name for remote fixture execution, use `kici run remote ` instead. For local workflow execution, use `kici run --local`. ### kici local Manage the **local dev plane** — the warm, per-user orchestrator (plus its own local PostgreSQL) that [`kici run --local`](https://docs.kici.dev/user/cli/runs-and-approvals/#kici-run-event---local) dispatches through. You rarely need these commands directly: a local run boots the plane on demand and reuses it afterwards. Reach for them to inspect the plane, stop it, read its log, or switch it between offline and Platform-connected mode. ```bash kici local up [--offline | --connected] # Start, or reuse an already-running plane kici local status [--json] # Port, pid, PostgreSQL backend, attachment mode, readiness kici local down # Stop the orchestrator and its PostgreSQL, verifying the port is freed kici local logs # Print the plane log paths and rotation policy kici local attach # Attach to the Platform (hybrid mode) kici local detach # Return the plane to offline (independent) mode kici local trust-root # Export the dev-signed trust root for offline verification ``` The plane runs in one of two modes: - **Independent (offline)** — the default for a plane that has never been attached. Identity tokens and attestations are signed by a local dev key under the clearly non-production issuer `kici-local`. - **Hybrid (attached)** — `kici local attach` mints an org-scoped key with your logged-in credentials and reboots the plane connected to the Platform, so local runs get real Platform-minted identity and attestation. `kici local up` honors a durable attachment record: an attached plane comes back up hybrid, and falls back to offline with a warning when the Platform is unreachable. `--offline` forces an independent boot without clearing the attachment record (only `detach` clears it); `--connected` requires an attached, reachable Platform and fails otherwise. `kici local down` reports success only once the plane port is verified free. If a process still holds it — including a plane left behind by an interrupted boot — the command exits non-zero and names the holder, so a failed teardown is never mistaken for a clean one. A holder that does not identify as a KiCI plane orchestrator is reported and left alone, never stopped. `kici local status` reports a plane whose process is alive but whose readiness probe fails — for example when its PostgreSQL has stopped — as running but not ready, together with its readiness checks, rather than as not running. When the holder is a KiCI plane orchestrator that this config directory did not start — a plane belonging to another `KICI_CONFIG_DIR`, or one whose record here was lost — status names it as such rather than as not ready, since its readiness is never probed, and points at `kici local down`, which does reclaim it. When the port is held by a process that is not a KiCI plane orchestrator, `kici local status` names that holder instead and points at `KICI_LOCAL_ORCH_PORT`, because `kici local down` will not stop it. The plane writes its orchestrator log to `orchestrator.log` in its state directory and, when it runs embedded PostgreSQL, the PostgreSQL log to `orchestrator.log.pg` beside it. `kici local logs` prints their location. Each is rotated to a `.1` sibling when it reaches 50 MB, at the next plane start — the plane keeps the current log and one previous generation, so neither can grow without bound. Pass `--json` for machine-readable output. It prints one object and exits 0 for every state, including when the plane is stopped — the state is in the payload, not the exit code: ```bash $ kici local status --json {"state":"ready","running":true,"pid":3768093,"port":4319, "url":"http://127.0.0.1:4319","pgKind":"embedded","stampVersion":3, "mode":"independent"} ``` `state` is one of `stopped`, `ready`, `unready`, `foreign-kici`, `foreign-unknown`. The key set is fixed: the plane's admin token is never part of it, so the output is safe to log. Every key is always present, but only `state`, `running` and `mode` always carry a value — the rest are `null` whenever the plane cannot supply them (for `stopped` that is all of them, and `stampVersion` is populated only for `ready`), so read them defensively (`jq -r '.pid // empty'`). A local run also fails fast when no agent claims it: if no scaler label set matches the job's `runsOn`, or the agent cannot start, the run gives up within a short acceptance window (2 minutes by default, overridable with `KICI_LOCAL_ACCEPTANCE_TIMEOUT_MS`) and names the plane log instead of waiting out the full no-progress timeout. **Verifying an offline-signed bundle:** export the plane's trust root, then pass it to the verifier: ```bash kici local trust-root ./local-trust-root.json kici verify-attestation ./dist/app.tgz \ --bundle ./app.tgz.kici.json \ --trust-root ./local-trust-root.json ``` For the plane's on-disk layout, port selection, PostgreSQL backends, and reset behavior, see [Local dev plane](https://docs.kici.dev/operator/orchestrator/local-dev-plane/). ### kici fixture Generate a fixture template for an event type. Useful for creating custom test payloads. ```bash kici fixture [options] ``` **Valid events:** `pr:open`, `pr:sync`, `pr:close`, `pr:reopen`, `push`, `tag`, `comment`, `review`, `review_comment`, `release`, `dispatch`, `create`, `delete`, `status`, `workflow_run`, `fork`, `star`, `watch`, `kici_event`, `workflow_complete`, `workflows_failed_batch`, `job_complete`, `generic_webhook`, `schedule`, `lifecycle` (many support `:action` suffixes, e.g. `comment:edited`, `release:published`, `lifecycle:workflow_complete`). `webhook:` is a shorthand alias for `generic_webhook:`. **Examples:** ```bash # Print fixture to stdout kici fixture pr:open # Write fixture to file kici fixture pr:open --output fixtures/pr-open.json # Generate push fixture kici fixture push --output fixtures/push.json ``` Use generated fixtures as reference when writing test fixture files in `.kici/tests/`: ```bash kici fixture pr:open --output fixtures/pr-open-reference.json # Use the generated JSON as reference when writing .kici/tests/pr-open.ts ``` ### kici types Generate TypeScript declaration files from orchestrator environment metadata. The generated `.d.ts` file augments the SDK's `KnownSecretKeys` and `ContextSecrets` interfaces, providing compile-time autocomplete and type checking for secret key names. ```bash kici types [options] ``` **Prerequisites:** Authenticate via `kici login` to fetch the real key set. Without it, `kici types` writes an empty stub (see "Offline behavior" below). **Output:** `.kici/types/secrets.d.ts` **Examples:** ```bash # Generate types from orchestrator kici types # Use custom .kici directory kici types --kici-dir packages/app/.kici ``` **How it works:** 1. Fetches all environment metadata (environment names and secret key names) from the orchestrator 2. Generates a `.d.ts` file that augments `@kici-dev/sdk`'s `KnownSecretKeys` and `ContextSecrets` interfaces 3. Writes the file to `.kici/types/secrets.d.ts` After generating types, `ctx.secrets.get('MY_KEY')` and `ctx.secrets.expose('DB_HOST')` gain autocomplete and type checking in your IDE. **Git workflow:** `.kici/types/secrets.d.ts` is a local development aid, not source — its content is a snapshot of one org's secret keys fetched from the Platform. `kici init` gitignores `.kici/types/`, so the file stays out of version control. Each team member runs `kici types` (or an authenticated `kici compile`) to generate their own copy. Do not commit it: a stale committed copy would type-check against secret keys that no longer exist. **Offline behavior:** When the Platform cannot be reached — not logged in, no active org, or offline — `kici types` never fails. If a `secrets.d.ts` already exists, `kici types` keeps it untouched, so a transient outage does not wipe your real key set. If the file is absent (a fresh clone or unauthenticated CI), `kici types` writes a valid empty stub. Type checking then degrades to "no known keys" (any key name is accepted) rather than breaking with "module has no exported member". Run `kici types` again once authenticated to refresh it. **Auto-regeneration:** `kici compile` automatically runs `kici types` after successful compilation when authenticated. See the [kici compile](https://docs.kici.dev/user/cli/authoring-and-local/#kici-compile) section for details. **Escape hatch:** For dynamic keys not in the generated types, use a cast: `(ctx.secrets as any).DYNAMIC_KEY`. ### kici workflows list List permanently registered workflows on the orchestrator. ```bash kici workflows list [options] ``` **Examples:** ```bash # List all registered workflows kici workflows list # JSON output for scripting kici workflows list --json # Show workflows not updated in 30 days kici workflows list --stale 30d # Filter by trigger type kici workflows list --trigger-type push # Filter by repository kici workflows list --repo my-org/my-repo ``` ### kici hook install Install a pre-commit hook that runs `kici compile` before each commit. ```bash kici hook install [options] ``` **Examples:** ```bash # Auto-detect hook tool (husky, lint-staged, etc.) kici hook install # Force raw git hook kici hook install --git ``` The command auto-detects existing hook tools in your project: - **Husky**: Adds to `.husky/pre-commit` - **lint-staged**: Adds to lint-staged configuration - **Raw git**: Writes `.git/hooks/pre-commit` If multiple tools are detected, you are prompted to choose. ### kici docs Open the KiCI documentation site in the default browser. With the `llm` subcommand, print the LLM-friendly documentation bundle that ships with `@kici-dev/compiler` — pipe it into a coding agent's context buffer to brief the agent on authoring conventions without an internet round-trip. ```bash kici docs # open https://kici.dev/docs/ kici docs --no-open # print the URL instead of opening a browser kici docs llm # print the llms.txt index (a router over the task bundles) kici docs llm sdk # print the SDK task bundle kici docs llm full # print llms-full.txt (every page in one file) kici docs llm sdk --out sdk-context.md # write a bundle to a file ``` **Examples:** ```bash # Open the docs site in your browser kici docs # Pipe just the SDK bundle into a coding agent (small, task-scoped context) kici docs llm sdk | claude -- "Read this and help me author a deploy workflow" # Save the router index for offline reference kici docs llm --out kici-llms-index.txt ``` Bundles are regenerated from `docs/` every time `@kici-dev/compiler` is built, so they always match your installed CLI version. The index lists each task bundle — `getting-started`, `sdk`, `sdk-runtime`, `cli`, `cli-remote`, `patterns`, `features`, `features-execution`, `providers`, `architecture` — with its size and a one-line purpose; pass the bundle id as the topic. Every cross-reference link inside a bundle is an absolute `docs.kici.dev` URL. The same files are published online following the [llms.txt convention](https://llmstxt.org/). ## Reference ### `kici compile` Compile workflows from .kici/workflows/ to kici.lock.json Synopsis: `kici compile [options]` **Options** | Option | Default | Description | | ------------------- | ------- | ---------------------------------------------------------------------------------- | | `--check` | `false` | Validate workflows and type-check sources (tsc --noEmit) without writing lock file | | `--kici-dir ` | `.kici` | Path to .kici directory | | `--verbose` | `false` | Detailed output | | `--watch` | `false` | Watch for changes and recompile | ### `kici docs` Open the KiCI documentation site in the default browser Synopsis: `kici docs [options]` **Options** | Option | Default | Description | | ----------- | ------- | ----------------------------------------------- | | `--no-open` | | Print the docs URL instead of opening a browser | ### `kici docs llm` Print KiCI LLM docs bundles. No topic prints the llms.txt index; prints a task bundle (e.g. sdk, cli, cli-remote, patterns, features, providers, architecture, getting-started); "full" prints the complete bundle. Synopsis: `kici docs llm [topic] [options]` **Arguments** | Argument | Required | Variadic | Description | | -------- | -------- | -------- | ----------- | | `topic` | no | no | | **Options** | Option | Default | Description | | -------------- | ------- | -------------------------------------------- | | `--out ` | | Write the bundle to a file instead of stdout | ### `kici fixture` Generate fixture template for event type Synopsis: `kici fixture [options]` **Arguments** | Argument | Required | Variadic | Description | | -------- | -------- | -------- | ------------------------------------------------------------------------------------------ | | `event` | yes | no | Event to generate fixture for (e.g., pr:open, push, schedule, lifecycle:workflow_complete) | **Options** | Option | Default | Description | | ----------------- | ------- | ------------------------------- | | `--output ` | | Write to file instead of stdout | ### `kici hook` Manage pre-commit hooks Synopsis: `kici hook` ### `kici hook install` Install kici compile pre-commit hook Synopsis: `kici hook install [options]` **Options** | Option | Default | Description | | ------- | ------- | ---------------------------------------- | | `--git` | `false` | Use raw git hook (.git/hooks/pre-commit) | ### `kici local` Manage the local dev orchestrator plane Synopsis: `kici local` ### `kici local attach` Attach the local dev plane to the Platform (hybrid) Synopsis: `kici local attach` ### `kici local detach` Detach the local dev plane from the Platform (offline) Synopsis: `kici local detach` ### `kici local down` Stop the local dev plane Synopsis: `kici local down` ### `kici local logs` Print the local dev plane log paths and rotation policy Synopsis: `kici local logs` ### `kici local status` Show local dev plane status and control commands Synopsis: `kici local status [options]` **Options** | Option | Default | Description | | -------- | ------- | ---------------------------------------------------- | | `--json` | `false` | Emit machine-readable JSON (exits 0 for every state) | ### `kici local trust-root` Export the offline dev-signed identity trust root ({ issuer, jwks }) to a file Synopsis: `kici local trust-root ` **Arguments** | Argument | Required | Variadic | Description | | -------- | -------- | -------- | ---------------------------------------------------- | | `file` | yes | no | Output path for the { issuer, jwks } trust-root JSON | ### `kici local up` Start (or reuse) the local dev plane Synopsis: `kici local up [options]` **Options** | Option | Default | Description | | ------------- | ------- | ---------------------------------------------------------------------------- | | `--offline` | `false` | Force the independent (offline) plane (does not clear the attachment record) | | `--connected` | `false` | Force the connected/hybrid plane (requires an attached, reachable Platform) | ### `kici preview` Preview which workflows match a trigger event (no execution) Synopsis: `kici preview [event] [options]` **Arguments** | Argument | Required | Variadic | Description | | -------- | -------- | -------- | ----------------------------------------------------- | | `event` | no | no | Event type to preview (e.g., push, pr:open, schedule) | **Options** | Option | Default | Description | | --------------------------- | ------- | ------------------------------------------------------------ | | `--branch ` | | Override target branch for trigger matching (default: main) | | `--sha ` | | Override commit SHA | | `--workflow ` | | Filter to specific workflow in display | | `--job ` | | Filter to specific job in display | | `--debug` | `false` | Verbose internals | | `--kici-dir ` | `.kici` | Path to .kici directory | | `--files ` | | Simulate changed file path for trigger matching (repeatable) | | `--secret ` | | Inject flat secret (repeatable) | | `--context ` | | Inject context secret (repeatable) | ### `kici types` Generate TypeScript declarations for secret contexts Synopsis: `kici types [options]` **Options** | Option | Default | Description | | ------------------- | ------- | ----------------------- | | `--kici-dir ` | `.kici` | Path to .kici directory | ### `kici workflows` Manage workflow registrations Synopsis: `kici workflows` ### `kici workflows list` List permanently registered workflows Synopsis: `kici workflows list [options]` **Options** | Option | Default | Description | | ----------------------- | ------- | ------------------------------------------ | | `--json` | `false` | Output as JSON | | `--stale ` | | Filter stale registrations (e.g., 30d, 7d) | | `--trigger-type ` | | Filter by trigger type | | `--repo ` | | Filter by repository | ---