assay-lua 0.7.2

General-purpose enhanced Lua runtime. Batteries-included scripting, automation, and web services.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# AGENTS.md

## Skills & Rules

Key coding practices for this project:

- **autonomous-workflow** — Proposal-first development, decision authority, commit hygiene
- **code-quality** — Warnings-as-errors, no underscore prefixes, test coverage, type safety

## What is Assay

General-purpose enhanced Lua runtime. Single ~9 MB static binary with batteries included: HTTP
client/server, JSON/YAML/TOML, crypto, database, WebSocket, filesystem, shell execution, process
management, async, and 29 embedded stdlib modules for infrastructure services (Kubernetes,
Prometheus, Vault, ArgoCD, etc.) and AI agent integrations (OpenClaw, GitHub, Gmail, Google
Calendar).

Use cases:

- **Standalone scripting** — system automation, CI/CD tasks, file processing
- **Embedded runtime** — other Rust services embed assay as a library (`pub mod lua`)
- **Kubernetes Jobs** — replaces 50–250 MB Python/Node/kubectl containers (~9 MB image)
- **Infrastructure automation** — GitOps hooks, health checks, service configuration

- **Repo**: [github.com/developerinlondon/assay]https://github.com/developerinlondon/assay
- **Image**: `ghcr.io/developerinlondon/assay:latest` (~9 MB compressed)
- **Crate**: [crates.io/crates/assay-lua]https://crates.io/crates/assay-lua
- **Stack**: Rust (2024 edition), Tokio, Lua 5.5 (mlua), reqwest, clap, axum

## Two Modes

```bash
assay script.lua     # Lua mode — run script with all builtins
assay checks.yaml    # YAML mode — structured checks with retry/backoff/parallel
```

### Example: Kubernetes Job

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: configure-service
  annotations:
    argocd.argoproj.io/hook: PostSync
spec:
  template:
    spec:
      containers:
        - name: configure
          image: ghcr.io/developerinlondon/assay:latest
          command: ["assay", "/scripts/configure.lua"]
          volumeMounts:
            - name: scripts
              mountPath: /scripts
          env:
            - name: SERVICE_URL
              value: "http://my-service:8080"
            - name: API_TOKEN
              valueFrom:
                secretKeyRef:
                  name: my-service-credentials
                  key: admin_api_token
      volumes:
        - name: scripts
          configMap:
            name: postsync-scripts
      restartPolicy: Never
```

## Built-in Globals

Available in all `.lua` scripts — no `require` needed:

| Category       | Functions                                                                                                                                                                                                                                                           |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP           | `http.get(url, opts?)`, `http.post(url, body, opts?)`, `http.put(url, body, opts?)`, `http.patch(url, body, opts?)`, `http.delete(url, opts?)`, `http.serve(port, routes)`                                                                                          |
| JSON/YAML/TOML | `json.parse(str)`, `json.encode(tbl)`, `yaml.parse(str)`, `yaml.encode(tbl)`, `toml.parse(str)`, `toml.encode(tbl)`                                                                                                                                                 |
| Filesystem     | `fs.read(path)`, `fs.write(path, str)`, `fs.remove(path)`, `fs.list(path)`, `fs.stat(path)`, `fs.mkdir(path)`, `fs.exists(path)`, `fs.copy(src, dst)`, `fs.rename(src, dst)`, `fs.glob(pattern)`, `fs.tempdir()`, `fs.chmod(path, mode)`, `fs.readdir(path, opts?)` |
| Crypto         | `crypto.jwt_sign(claims, key, alg, opts?)`, `crypto.hash(str, alg)`, `crypto.hmac(key, data, alg?, raw?)`, `crypto.random(len)`                                                                                                                                     |
| Base64         | `base64.encode(str)`, `base64.decode(str)`                                                                                                                                                                                                                          |
| Regex          | `regex.match(pat, str)`, `regex.find(pat, str)`, `regex.find_all(pat, str)`, `regex.replace(pat, str, repl)`                                                                                                                                                        |
| Database       | `db.connect(url)`, `db.query(conn, sql, params?)`, `db.execute(conn, sql, params?)`, `db.close(conn)`                                                                                                                                                               |
| WebSocket      | `ws.connect(url)`, `ws.send(conn, msg)`, `ws.recv(conn)`, `ws.close(conn)`                                                                                                                                                                                          |
| Templates      | `template.render(path, vars)`, `template.render_string(tmpl, vars)`                                                                                                                                                                                                 |
| Async          | `async.spawn(fn)`, `async.spawn_interval(fn, ms)`, `handle:await()`, `handle:cancel()`                                                                                                                                                                              |
| Assert         | `assert.eq(a, b, msg?)`, `assert.ne(a, b, msg?)`, `assert.gt(a, b, msg?)`, `assert.lt(a, b, msg?)`, `assert.contains(str, sub, msg?)`, `assert.not_nil(val, msg?)`, `assert.matches(str, pat, msg?)`                                                                |
| Logging        | `log.info(msg)`, `log.warn(msg)`, `log.error(msg)`                                                                                                                                                                                                                  |
| Utilities      | `env.get(key)`, `env.set(key, value)`, `env.list()`, `sleep(secs)`, `time()`                                                                                                                                                                                        |
| Shell          | `shell.exec(cmd, opts?)` — execute commands with timeout, working dir, env                                                                                                                                                                                          |
| Process        | `process.list()`, `process.is_running(name)`, `process.kill(pid, signal?)`, `process.wait_idle(names, timeout, interval)`                                                                                                                                           |
| Disk           | `disk.usage(path)` — returns `{total, used, available, percent}`, `disk.sweep(dir, age_secs)`, `disk.dir_size(path)`                                                                                                                                                |
| OS             | `os.hostname()`, `os.arch()`, `os.platform()`                                                                                                                                                                                                                       |
| Temporal (gRPC) | `temporal.connect(opts)`, `temporal.start(opts)` — native gRPC workflow client (requires `temporal` feature) |

HTTP responses: `{status, body, headers}`. Options: `{headers = {["X-Key"] = "val"}}`.

### `http.serve` Response Shapes

Route handlers return a table. Three shapes are supported:

```lua
-- Body response (default Content-Type: text/plain)
return { status = 200, body = "hello" }

-- JSON response (default Content-Type: application/json)
return { status = 200, json = { ok = true } }

-- SSE streaming response (default Content-Type: text/event-stream)
return {
  status = 200,
  sse = function(send)
    send({ data = "connected" })
    sleep(1)  -- async builtins work inside SSE handlers
    send({ event = "update", data = json.encode({ count = 1 }), id = "1" })
    -- stream closes when function returns
  end
}
```

Custom headers override defaults: `headers = { ["content-type"] = "text/html" }`.

SSE `send()` accepts: `event` (string), `data` (string), `id` (string), `retry` (integer). `event`
and `id` must not contain newlines. `data` handles multi-line automatically.

## Stdlib Modules

29 embedded Lua modules loaded via `require("assay.<name>")`:

| Module                | Description                                                                       |
| --------------------- | --------------------------------------------------------------------------------- |
| `assay.prometheus`    | Query metrics, alerts, targets, rules, label values, series                       |
| `assay.alertmanager`  | Manage alerts, silences, receivers, config                                        |
| `assay.loki`          | Push logs, query, labels, series                                                  |
| `assay.grafana`       | Health, dashboards, datasources, annotations                                      |
| `assay.k8s`           | 30+ resource types, CRDs, readiness checks                                        |
| `assay.argocd`        | Apps, sync, health, projects, repositories                                        |
| `assay.kargo`         | Stages, freight, promotions, verification                                         |
| `assay.flux`          | GitRepositories, Kustomizations, HelmReleases                                     |
| `assay.traefik`       | Routers, services, middlewares, entrypoints                                       |
| `assay.vault`         | KV secrets, policies, auth, transit, PKI                                          |
| `assay.openbao`       | Alias for vault (API-compatible)                                                  |
| `assay.certmanager`   | Certificates, issuers, ACME challenges                                            |
| `assay.eso`           | ExternalSecrets, SecretStores, ClusterSecretStores                                |
| `assay.dex`           | OIDC discovery, JWKS, health                                                      |
| `assay.crossplane`    | Providers, XRDs, compositions, managed resources                                  |
| `assay.velero`        | Backups, restores, schedules, storage locations                                   |
| `assay.temporal`      | Workflows, task queues, schedules, signals + native gRPC client (temporal feature) |
| `assay.harbor`        | Projects, repositories, artifacts, vulnerability scanning                         |
| `assay.healthcheck`   | HTTP checks, JSON path, body matching, latency, multi-check                       |
| `assay.s3`            | S3-compatible storage (AWS, R2, MinIO) with Sig V4                                |
| `assay.postgres`      | Postgres-specific helpers                                                         |
| `assay.zitadel`       | OIDC identity management with JWT machine auth                                    |
| `assay.unleash`       | Feature flags: projects, environments, features, strategies, API tokens           |
| `assay.openclaw`      | OpenClaw AI agent platform — invoke tools, state, diff, approve, LLM tasks        |
| `assay.github`        | GitHub REST API — PRs, issues, actions, repos, GraphQL                            |
| `assay.gmail`         | Gmail REST API with OAuth2 — search, read, reply, send, labels                    |
| `assay.gcal`          | Google Calendar REST API with OAuth2 — events CRUD, calendar list                 |
| `assay.oauth2`        | Google OAuth2 token management — file-based credentials, auto-refresh, persistence |
| `assay.email_triage`  | Email classification — deterministic rules + optional LLM-assisted triage via OpenClaw |

### Client Pattern

Every stdlib module follows the same structure:

```lua
local grafana = require("assay.grafana")
local c = grafana.client("http://grafana:3000", { api_key = "glsa_..." })
local h = c:health()
assert.eq(h.database, "ok")
```

1. `require("assay.<name>")` returns module table `M`
2. `M.client(url, opts?)` creates a client with auth config
3. Client methods use `c:method()` (colon = implicit self)
4. Errors raised via `error()` — use `pcall()` to catch

Auth varies by service: `{ token = "..." }`, `{ api_key = "..." }`,
`{ username = "...", password = "..." }`.

## Tool Mode (OpenClaw Integration)

Assay v0.6.0 adds tool mode for integration with OpenClaw AI agents:

```bash
assay run --mode tool script.lua                    # Run as agent tool, structured JSON output
assay resume --token <token> --approve yes|no       # Resume paused approval gate
```

Tool mode produces structured JSON output suitable for agent consumption. When a script hits an
approval gate (via `openclaw.approve()`), execution pauses and returns a resume token. The agent or
human can then approve or reject via `assay resume`.

```
+-------------------------------------------------------+
| OpenClaw Agent                                        |
|   |                                                   |
|   +--> assay run --mode tool deploy.lua               |
|   |      |                                            |
|   |      +--> approval gate -> pauses, returns token  |
|   |                                                   |
|   +--> human reviews                                  |
|   |                                                   |
|   +--> assay resume --token <t> --approve yes         |
|          |                                            |
|          +--> script resumes from gate                |
+-------------------------------------------------------+
```

### OpenClaw Extension

The `@developerinlondon/assay-openclaw-extension` package (GitHub Packages) registers Assay as an
OpenClaw agent tool:

```bash
# One-time: configure npm to use GitHub Packages for @developerinlondon scope
echo "@developerinlondon:registry=https://npm.pkg.github.com" >> ~/.npmrc

# Install the extension
openclaw plugins install @developerinlondon/assay-openclaw-extension
```

Configuration in OpenClaw plugin config:

| Key              | Default        | Description                              |
| ---------------- | -------------- | ---------------------------------------- |
| `binaryPath`     | PATH lookup    | Explicit path to the `assay` binary      |
| `timeout`        | `20`           | Execution timeout in seconds             |
| `maxOutputSize`  | `524288`       | Maximum stdout collected from Assay      |
| `scriptsDir`     | workspace root | Root directory for Lua scripts           |

See [openclaw-extension/README.md](openclaw-extension/README.md) for full details.

## Adding a New Stdlib Module

No Rust changes needed. Modules are auto-discovered via `include_dir!("$CARGO_MANIFEST_DIR/stdlib")`
in `src/lua/mod.rs`.

### 1. Create `stdlib/<name>.lua`

Follow the client pattern. Reference `grafana.lua` (simple, 110 lines) or `vault.lua`
(comprehensive, 330 lines):

```lua
local M = {}

function M.client(url, opts)
  opts = opts or {}
  local c = {
    url = url:gsub("/+$", ""),
    token = opts.token,
  }

  local function headers(self)
    local h = { ["Content-Type"] = "application/json" }
    if self.token then h["Authorization"] = "Bearer " .. self.token end
    return h
  end

  local function api_get(self, path_str)
    local resp = http.get(self.url .. path_str, { headers = headers(self) })
    if resp.status ~= 200 then
      error("<name>: GET " .. path_str .. " HTTP " .. resp.status .. ": " .. resp.body)
    end
    return json.parse(resp.body)
  end

  local function api_post(self, path_str, payload)
    local resp = http.post(self.url .. path_str, payload, { headers = headers(self) })
    if resp.status ~= 200 and resp.status ~= 201 then
      error("<name>: POST " .. path_str .. " HTTP " .. resp.status .. ": " .. resp.body)
    end
    return json.parse(resp.body)
  end

  function c:health()
    return api_get(self, "/api/health")
  end

  return c
end

return M
```

Conventions:

- `api_get/api_post/api_put/api_delete` are local helpers, not exported
- Error format: `"<module>: <METHOD> <path> HTTP <status>: <body>"`
- Strip trailing slashes: `url:gsub("/+$", "")`
- All HTTP uses builtins (`http.get`, `json.parse`) — no external requires
- 404 = nil pattern: check `resp.status == 404`, return `nil`
- Idempotent helpers go on `M` (module level), not on the client

### 2. Create `tests/stdlib_<name>.rs`

Wiremock-based tests. One test per client method:

```rust
mod common;

use common::run_lua;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[tokio::test]
async fn test_require_<name>() {
    let script = r#"
        local mod = require("assay.<name>")
        assert.not_nil(mod)
        assert.not_nil(mod.client)
    "#;
    run_lua(script).await.unwrap();
}

#[tokio::test]
async fn test_<name>_health() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/api/health"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "status": "OK"
        })))
        .mount(&server)
        .await;

    let script = format!(
        r#"
        local mod = require("assay.<name>")
        local c = mod.client("{}", {{ token = "test-token" }})
        local h = c:health()
        assert.eq(h.status, "OK")
        "#,
        server.uri()
    );
    run_lua(&script).await.unwrap();
}
```

Note: Lua tables in `format!` strings need doubled braces: `{{ key = "value" }}`.

### 3. Update `README.md`

Add the module to the stdlib table.

### 4. Verify

```bash
cargo check && cargo clippy -- -D warnings && cargo test
```

## Directory Structure

```
assay/
├── Cargo.toml
├── Cargo.lock
├── AGENTS.md                 # This file
├── Dockerfile                # Multi-stage: rust builder -> scratch
├── src/
│   ├── main.rs               # CLI entry point (clap)
│   ├── lib.rs                # Library root (pub mod lua)
│   ├── config.rs             # YAML config parser (checks.yaml)
│   ├── runner.rs             # Orchestrator: retries, backoff, timeout
│   ├── output.rs             # Structured JSON results + exit code
│   ├── checks/
│   │   ├── mod.rs            # Check dispatcher
│   │   ├── http.rs           # HTTP check type (YAML mode)
│   │   ├── prometheus.rs     # Prometheus check type (YAML mode)
│   │   └── script.rs         # Lua script check type
│   └── lua/
│       ├── mod.rs            # VM setup, sandbox, stdlib loader (include_dir!)
│       ├── async_bridge.rs   # Async Lua execution, shebang stripping
│       └── builtins/
│           ├── mod.rs        # register_all() — wires builtins into Lua globals
│           ├── http.rs       # http.{get,post,put,patch,delete,serve} + SSE streaming
│           ├── json.rs       # json.{parse,encode}
│           ├── serialization.rs  # yaml + toml parse/encode
│           ├── core.rs       # env, sleep, time, fs, base64, regex, log, async
│           ├── assert.rs     # assert.{eq,ne,gt,lt,contains,not_nil,matches}
│           ├── crypto.rs     # crypto.{jwt_sign,hash,hmac,random}
│           ├── db.rs         # db.{connect,query,execute,close}
│           ├── ws.rs         # ws.{connect,send,recv,close}
│           ├── template.rs   # template.{render,render_string}
│           ├── disk.rs       # disk.{usage} + Lua helpers: disk.sweep, disk.dir_size
│           ├── os_info.rs    # os.{hostname,arch,platform}
│           └── temporal.rs   # temporal.{connect,start} + client methods (gRPC, optional)
├── stdlib/                   # Embedded Lua modules (auto-discovered)
│   ├── vault.lua             # Comprehensive reference (330 lines)
│   ├── grafana.lua           # Simple reference (110 lines)
│   └── ... (22 modules total)
├── tests/
│   ├── common/mod.rs         # Test helpers: run_lua(), create_vm(), eval_lua()
│   ├── stdlib_vault.rs       # One test file per stdlib module
│   └── ...
└── examples/                 # Example scripts and check configs
```

## Design Decisions (FINAL)

| Decision         | Choice   | Reason                                                                   |
| ---------------- | -------- | ------------------------------------------------------------------------ |
| Language runtime | Lua 5.5  | ArgoCD compatible, 30yr ecosystem, native int64, perf irrelevant for I/O |
| Not Luau         | Rejected | Lua 5.1 base, Roblox ecosystem, no int64                                 |
| Not Rhai         | Rejected | 6x slower, no async, no coroutines                                       |
| Not Wasmtime     | Rejected | Requires compile step, bad for script iteration                          |

## Release Process

After merging a feature PR, always create a version bump PR before moving on:

1. **Bump version** in `Cargo.toml` (triggers `Cargo.lock` update on next build)
2. **Update CHANGELOG.md** — add new version section at the top with date and changes
3. **Update AGENTS.md** — if new builtins/functions were added, update the tables
4. **Update all docs** — README.md, SKILL.md, site/modules.html, site/llms.txt, site/llms-full.txt
5. **Run checks**: `cargo clippy --tests -- -D warnings && cargo test`
6. **Create a new branch** (e.g., `chore/bump-0.5.6`), commit, push, open PR
7. **After merge**: tag the release (`git tag v0.5.6 && git push origin v0.5.6`)

Files to update per release:

- `Cargo.toml` — version field
- `CHANGELOG.md` — new version entry
- `AGENTS.md` — if API surface changed
- `README.md` — if API surface changed
- `SKILL.md` — if API surface changed
- `site/modules.html` — if API surface changed
- `site/llms.txt` — if API surface changed
- `site/llms-full.txt` — if API surface changed
- `openclaw-extension/package.json` — version field (auto-synced from git tag by CI, but keep
  in sync manually for local development)

The tag push triggers `.github/workflows/release.yml` which publishes:
- GitHub Release (binaries + checksums)
- crates.io (`assay-lua` crate)
- Docker image (`ghcr.io/developerinlondon/assay`)
- GitHub Packages npm (`@developerinlondon/assay-openclaw-extension`)

## Commands

```bash
cargo check                        # Type check
cargo clippy -- -D warnings        # Lint (warnings = errors)
cargo test                         # Run all tests
cargo build --release              # Release build (~9 MB)
```