From 3910074b0406990b9b4af4e5aa472e2172d9b276 Mon Sep 17 00:00:00 2001 From: Sutisna Date: Tue, 4 Aug 2026 12:06:53 +0200 Subject: [PATCH 1/6] feat(skills): agent-skills layout and the sawala-cli orientation skill Establish .agents/skills/ as the canonical Agent Skills directory (read by Codex and Copilot) with .claude/skills as a relative symlink for Claude Code. Adds the sawala-cli orientation skill: establish scope before acting, choose between CLI and MCP, the surface map, shared write conventions (PUT replacement), safety rails, and failure triage keyed to the CLI's real error strings. --- .agents/skills/README.md | 41 ++++++++ .agents/skills/sawala-cli/SKILL.md | 146 +++++++++++++++++++++++++++++ .claude/skills | 1 + .gitignore | 3 + 4 files changed, 191 insertions(+) create mode 100644 .agents/skills/README.md create mode 100644 .agents/skills/sawala-cli/SKILL.md create mode 120000 .claude/skills diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 0000000..c0ed2ae --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,41 @@ +# Agent Skills for the Sawala CLI + +Each subdirectory here is an [Agent Skill](https://agentskills.io) — a +`SKILL.md` with `name` + `description` frontmatter that an agent loads on +demand when a task matches the description. + +## Why `.agents/skills/` and not `.claude/skills/` + +The `SKILL.md` format is a cross-vendor standard, but the directory each tool +looks in is not: + +| Tool | Reads | +|---|---| +| OpenAI Codex | `.agents/skills/` **only** — not `.codex/skills`, not `.claude/skills` | +| GitHub Copilot | `.agents/skills/`, `.github/skills/`, or `.claude/skills/` | +| Claude Code | `.claude/skills/` | + +`.agents/skills/` is the only directory Codex and Copilot both read, so it is +canonical here. `.claude/skills` is a relative symlink to it, which covers +Claude Code without a second copy. There is no `.github/skills/` — Copilot +already reads `.agents/skills/`. + +On Windows without Developer Mode, Git may materialise the symlink as a text +file. If that happens, copy `.agents/skills/` to `.claude/skills/` and add the +copy to `.gitignore`. + +## What ships to customers + +Every skill here except `sawala-cli-dev` is published inside the `@sawala/cli` +npm package and installed by `sawala skills install`. `sawala-cli-dev` +documents how to contribute to *this monorepo* and is meaningless to a +customer, so it stays here only. + +## If you add a command or an MCP tool + +`scripts/check-skills-coverage.mjs` fails the build when a CLI command group or +an MCP tool name appears nowhere in these files. It also validates each skill +against the Agent Skills spec (name pattern, `name` matching the directory, +description length, body under 500 lines, no dangling relative links). + +Run it with `npm run check:skills`. diff --git a/.agents/skills/sawala-cli/SKILL.md b/.agents/skills/sawala-cli/SKILL.md new file mode 100644 index 0000000..9737abb --- /dev/null +++ b/.agents/skills/sawala-cli/SKILL.md @@ -0,0 +1,146 @@ +--- +name: sawala-cli +description: Orientation for driving Sawala Cloud from an agent — the `sawala` and `kodena` CLIs and the `@sawala/mcp` / `@sawala/kodena-mcp` MCP servers. Use when a task mentions Sawala, Kodena, Kontena, Datana, Ajena, Formulir, Berkasna, Sebar, Tugasna, or Akuna; when deciding whether to shell out or call an MCP tool; or when a Sawala command fails with an auth, org, or project error. +metadata: + sawala-cli-version: "0.13.0" +--- + +# Driving Sawala Cloud from an agent + +Sawala Cloud is a multi-tenant platform. Every call is scoped to an +**organization** and usually to a **project** inside it. That scope is read +from machine-global state on disk, not from your prompt — so the first job is +always to find out where you are pointed. + +## 1. Establish context before anything else + +Do this before your first read, and **always** before your first write. + +- MCP available → call `sawala_whoami`. +- Otherwise → run `sawala whoami`. + +It prints: + + Email you@example.com + Display name Your Name + Active org acme + Active project (not set) + Token source file + Token scope acme (label: "laptop") + +Read `Active org` and `Active project`. `(not set)` means every scoped command +will fail. **The remedy is to ask the user which project — not to guess, and +not to pick the first one from `sawala project list`.** + +> **Never run `sawala org use` or `sawala project use` on your own initiative.** +> They rewrite `~/.sawala/config`, which outlives your session and silently +> retargets the user's own next terminal command at a different tenant. If a +> switch is genuinely needed, say so and let the user run it. + +There is no `--org` / `--project` flag on the `sawala` CLI. See +[Scoping without mutating state](#8-scoping-without-mutating-state) — the +answer is an environment variable, and it has a trap. + +## 2. Choose the right surface + +**Prefer an MCP tool** when one exists: its input is schema-validated and its +output is structured JSON you can parse. The `@sawala/mcp` server covers +Kontena, Datana, Formulir, Berkasna, plus `whoami` / `list_orgs` / +`list_projects`. `@sawala/kodena-mcp` mirrors the Kodena CLI. + +**Shell out to the CLI** when there is no MCP equivalent (deploys, `login`, +Ajena flows, Sebar, Tugasna, Akuna), when you want `--dry-run`, or when the +user wants a command they can re-run themselves. + +**Never hand-roll an HTTP call to `/cli/...`.** That surface is gated by a +`requireCliToken` middleware and rejects anything that is not a CLI token with +403 `cli_token_required` — an SDK API key or a dashboard session will not work. + +## 3. The surface map + +| Ask about | Command group | Deeper skill | +|---|---|---| +| content schemas, entries, locales | `sawala kontena` | `sawala-kontena` | +| collections, records, filters | `sawala datana` | `sawala-datana` | +| boards, tasks, backlog | `sawala tugasna` | `sawala-tugasna` | +| email domains, senders, messages | `sawala sebar` | `sawala-sebar` | +| end-user accounts, data residency | `sawala akuna` | `sawala-akuna` | +| automations / flows | `sawala ajena` | `sawala-ajena` | +| deploying sites and workers | `kodena` | `kodena-deploy` | + +Two products have no deeper skill because they hold no traps: + +- **Formulir** — `sawala formulir form list|get `, + `sawala formulir submission list |get `. + Project-scoped. **Read-only from the CLI: there is no create, update, or + delete.** Do not invent one. +- **Berkasna** — `sawala berkasna asset list|get ` (id is a ULID). + **Org-scoped, not project-scoped**, and likewise **read-only from the CLI.** + +Discover the rest at runtime rather than trusting this page if it looks stale: +`sawala --help`, `sawala --help`, `kodena --help`, and for MCP the +host's own tool listing. + +## 4. Shared write conventions + +These hold across every product with a write surface, so the product skills +do not repeat them. + +- Bodies come from `-f, --file ` (`-` means stdin) or `-d, --data `. + Passing both is an error. +- **`update` is a PUT replacement.** Adding one field means: `get` the current + document, append to the object you got back, and send the whole thing. Never + send a body containing only the new field — it deletes everything else. +- Kontena and Datana have a draft/published lifecycle with explicit + `publish` / `unpublish` verbs. Creating leaves a draft unless `--publish`. +- Reads print pretty JSON. Lists print terse padded columns. +- A non-zero exit means the message on stderr is the error. + +## 5. Safety rails + +- **`--dry-run` first** for any write you generated rather than the user dictated. + It prints `{ wouldSend: { method, body } }` and writes nothing. Show it to them. +- **Destructive verbs need `-y, --yes`.** Without a TTY they refuse outright: + `Refusing destructive operation without --yes (no TTY for confirmation prompt).` + That refusal is a feature. Surface the deletion and get approval — **do not + add `--yes` on your own initiative to make a command succeed.** + +## 6. Failure triage + +| Message | Cause | Do this | +|---|---|---| +| `Not logged in. Run \`sawala login\` or set SAWALA_API_TOKEN.` | no credential | tell the user to run `sawala login` | +| `No active org. Run \`sawala org use \`…` | scope unset | ask which org | +| `No active project. Run \`sawala project use \`…` | scope unset | ask which project | +| `No active project id. Re-run \`sawala project use \` to refresh.` | config predates the id field, **or** `SAWALA_PROJECT` is set — see §8 | ask the user to re-run `project use` | +| `Token is scoped to 'x'; cannot target 'y'` | org-scoped token vs different active org | user must switch tokens or mint a new one | +| 403 `cli_token_required` | credential is an API key or session, not a CLI token | `sawala login` | +| `That doesn't look like a Sawala CLI token` | malformed `SAWALA_API_TOKEN` | tokens are `koda_` + 32 chars of `A-Z2-7` | +| Cloudflare `Authentication error` during deploy | usually an API-token **scope gap**, not a wrong token | see `kodena-deploy` | + +## 7. Where credentials live + +`~/.sawala/credentials` and `~/.sawala/config`, written by `sawala login`. +Override the whole directory with `SAWALA_CONFIG_DIR` (absolute path, no `~` +expansion). The token may instead come from `SAWALA_API_TOKEN`; `whoami` +reports which via `Token source` (`file` | `env` | `flag`). + +**Never print, log, or echo a token.** If you need to prove auth works, run +`sawala whoami` and show that instead. + +## 8. Scoping without mutating state + +`SAWALA_ORG` and `SAWALA_PROJECT` override the active org/project for a single +invocation without touching `~/.sawala/config`: + + SAWALA_ORG=acme sawala berkasna asset list + +**Trap — do not use `SAWALA_PROJECT` with Kontena or Datana.** Those two +resolve a project **ULID** (`activeProjectId`) into the URL path, and that id +is persisted only by `sawala project use`; it has no environment-variable +equivalent. Setting `SAWALA_PROJECT=other` changes the slug but leaves the id +pointing at whatever project was last selected, so the request carries a +mismatched slug and id. Either it errors, or it acts on the wrong project. + +`SAWALA_PROJECT` is safe for project-scoped products that take no id in the +path — Formulir and Ajena. When in doubt, ask the user to `project use`. diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.gitignore b/.gitignore index b8b30b1..2b18cb9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ coverage/ *.tsbuildinfo *.tgz + +# Local agent worktrees (see .claude/worktrees/) +.claude/worktrees/ From 442407e3b0f9b97bd6854c365cf1a9ff7f761f0d Mon Sep 17 00:00:00 2001 From: Sutisna Date: Tue, 4 Aug 2026 13:01:35 +0200 Subject: [PATCH 2/6] feat(skills): sawala-kontena and sawala-datana Datana: collections, records, the filter grammar, the pipeline plane, and a references/field-types.md covering every SchemaField type and the options keys that are silently dropped. Kontena: schemas, entries, locales, and the single-vs-collection distinction. Documents the entry envelope ({locale,data,slug,status}) against Datana's bare field-values body, since confusing them is a guaranteed 422. Corrects two facts that no longer hold at HEAD: --q searches all text fields (not data.title), and record list defaults to limit=100 (not 25). --- .agents/skills/sawala-datana/SKILL.md | 140 ++++++++++++++++++ .../sawala-datana/references/field-types.md | 117 +++++++++++++++ .agents/skills/sawala-kontena/SKILL.md | 112 ++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 .agents/skills/sawala-datana/SKILL.md create mode 100644 .agents/skills/sawala-datana/references/field-types.md create mode 100644 .agents/skills/sawala-kontena/SKILL.md diff --git a/.agents/skills/sawala-datana/SKILL.md b/.agents/skills/sawala-datana/SKILL.md new file mode 100644 index 0000000..6b8adee --- /dev/null +++ b/.agents/skills/sawala-datana/SKILL.md @@ -0,0 +1,140 @@ +--- +name: sawala-datana +description: Model and query Datana collections and records from the Sawala CLI or MCP — create and change collections, add fields, import and filter records, populate relations, push pipeline events. Use when working with a Datana collection or record, adding a field to a collection, or when a Datana filter returns unexpectedly zero rows. +metadata: + sawala-cli-version: "0.13.0" +--- + +# Datana — collections and records + +Datana is the typed data store. A **collection** is a model (a list of typed +fields); a **record** is a row in it. Both live inside a project. + +Read `sawala-cli` first for scope, `--dry-run`, and `--yes` conventions. The +one that bites hardest here: **`update` is a PUT replacement.** + +Prefer the MCP tools (`sawala_datana_*`, 11 of them) when available; the CLI +group is `sawala datana` (14 subcommands). + +## Scope + +Every command needs an active org **and** an active project. Datana resolves +the project's **ULID** into the URL path, so a config that predates that field +fails with `No active project id. Re-run \`sawala project use \` to refresh.` +Do not try to work around this with `SAWALA_PROJECT` — see `sawala-cli` §8. + +## Collections + + sawala datana list # shortcut for `collection list` + sawala datana collection list + sawala datana collection get + sawala datana collection create -f model.json + sawala datana collection update -f model.json # PUT replacement + +Create body: `{ name, fields[], slug?, visibility?, pinned? }`. `slug` is +generated from `name` if omitted. `visibility` defaults to `private`; `public` +exposes a read-only API. A duplicate slug is a 409. + +**There is no `collection delete` in the CLI.** The service supports it +(`?cascade=true`, otherwise a non-empty collection 409s), but no command is +wired. Do not invent one — direct the user to the dashboard. + +### Adding a field — the operation agents get wrong + +`collection update` replaces the stored document. Sending only the new field +deletes every other field and orphans the data behind them. Always: + + # 1. pull + sawala datana collection get articles > /tmp/articles.json + + # 2. append the field to .fields[] in /tmp/articles.json, e.g. + # { "name": "featured", "type": "boolean", "required": false } + + # 3. show the user what will be sent + sawala datana collection update articles -f /tmp/articles.json --dry-run + + # 4. push the whole document + sawala datana collection update articles -f /tmp/articles.json + +See [field types](references/field-types.md) for the shape of every `type`, and +for the option flags that are **silently discarded** if you invent one. + +## Records + + sawala datana record list [--status] [--sort] [--filter] [--populate] [--q] [--limit] + sawala datana record get [--populate] + sawala datana record create -f data.json [--publish] + sawala datana record update -f data.json [--publish] + sawala datana record publish + sawala datana record unpublish + sawala datana record delete [-y] + +**The create/update body is the field values only** — the bare `data` object. +The CLI wraps it as `{ data, status }` for the API. Passing +`{"data": {...}}` yourself produces a record with a field literally named +`data`, and then a 422. + +**Only send fields declared in the collection.** An undeclared key is +`UNKNOWN_FIELD:` (422); a missing `required` field is +`MISSING_REQUIRED_FIELD:` (422). + +`record update` is also a PUT replacement — pull, edit, push, exactly as above. +To change only the lifecycle, use `publish` / `unpublish`: they send a PATCH +and do **not** resend the body, so they cannot clobber field values. + +The CLI cannot set a record's `dedupeKey`. The service supports it on create +(with `onConflict: 'skip' | 'upsert'`) but `record create` sends only +`{ data, status }`. Idempotent ingestion from the CLI is a **pipeline** +concern — see below. + +## Querying + +**`--filter`** is repeatable. The grammar is exactly: + + field:value field:in:a,b field:gte:N field:lte:N + +Array-valued fields (`multiselect`, many-relations) match if *any* element +matches. `gte`/`lte` coerce the value with `Number()`, so they are numeric only. + +> **Boolean filters never match — they return zero rows rather than an error.** +> The comparison is `json_extract(data, '$.field') = ''` with the value +> bound as a **string**, while SQLite returns a JSON boolean as the integer +> `1`/`0`. `1 = 'true'` is false. Fetch and filter client-side instead: +> +> sawala datana record list articles --limit 100 \ +> | ... # or use `record list` JSON via MCP and filter in your own code +> +> Treat any zero-row boolean filter as this bug, not as an empty collection. + +**`--sort`** takes `field` (ascending) or `-field` (descending), e.g. `-createdAt`. + +**`--populate`** takes comma-separated relation field names, or `*` for all. + +**`--q`** is a case-insensitive `LIKE` across the collection's **searchable** +fields: every field marked `options.searchable`, or — when none is marked, +which is most collections — **every `text` field**. `private` fields are never +searched. If the collection has no text field and none marked searchable, `q` +matches nothing at all. + +> The CLI and MCP help both say `q` searches "the record title (`data.title`)". +> **That text is stale.** The service deliberately widened it, because +> collections without a `title` field returned nothing for every search. + +**`--limit`** is clamped 1–100 by the service. The help text says the default is +25, but the CLI actually sends `limit=100` when the flag is omitted. Pass it +explicitly if the number matters. + +## Pipeline — the append-only analytical plane + + sawala datana pipeline create -f model.json + sawala datana pipeline push -f events.json [--dedupe-keys a,b] + +A pipeline collection uses the same typed-field schema but its records are +**events**: ingested append-only, never draft/published, never updated or +deleted, and always private. `pipeline create` forces `flavor: "pipeline"` and +`visibility: private` regardless of what the body says. + +`push` accepts a bare event object, an array, or an `{ event }` / `{ events }` +envelope. `--dedupe-keys a,b` names the fields whose combined value must be +unique — it is merged into the body unless the envelope already carries +`dedupeKeys`. This is the CLI's only idempotent-ingest path. diff --git a/.agents/skills/sawala-datana/references/field-types.md b/.agents/skills/sawala-datana/references/field-types.md new file mode 100644 index 0000000..54317ac --- /dev/null +++ b/.agents/skills/sawala-datana/references/field-types.md @@ -0,0 +1,117 @@ +# Datana / Kontena field types + +Both stores share one `SchemaField` definition (`@sawala/shared-types`), so +this file applies to Kontena schemas as well as Datana collections. + +## The field object + + { + "name": "featured", // required; the JSON key in a record's data + "type": "boolean", // required; one of the types below + "required": false, // required (the flag itself, not the value) + "label": "Featured", // optional display label + "labels": { "id": "Unggulan" }, // optional per-locale labels + "unique": false, // optional + "localized": false, // optional + "hidden": false, // optional; a Formulir UI concern + "private": false, // optional; strips the field from the public read API + "default": null, // optional + "validation": {}, // optional, free-form + "options": { }, // optional; see below — STRICTLY typed + "subfields": [ ] // repeater/component only; same shape, recursive + } + +`name` and `required` are the only two keys you must supply besides `type`. + +## Types + +| `type` | Value in a record | +|---|---| +| `text` | string | +| `url` | string | +| `richtext` | string (HTML) | +| `markdown` | string | +| `number` | number | +| `boolean` | `true` / `false` — **see the filter warning in SKILL.md** | +| `date` | ISO-8601 string | +| `select` | string, one of `options.enum` | +| `multiselect` | array of strings from `options.enum` | +| `relation` | target slug, or an array of slugs when `options.many` | +| `media` | public CDN URL object | +| `file-private` | `{ assetId, filename, mimeType, size }` — bytes in Berkasna's private bucket, read via short-lived signed URLs | +| `blocks` | array of block objects | +| `component` | object shaped by `subfields` | +| `repeater` | **array of objects** shaped by `subfields` | +| `json` | any JSON | + +Only `repeater` is deep-validated by the service: a non-array is +`INVALID_REPEATER_FORMAT:`, a non-object element is +`INVALID_REPEATER_ITEM:[i]`, and each element is validated against +`subfields` recursively. `media` and `relation` values are **not** deep-typed — +a malformed one is accepted on write and surfaces later in the dashboard. + +## `options` — undeclared keys are silently dropped + +`options` is validated by a plain (non-passthrough) object schema, so **any key +not in this list is stripped on write with no error**. The write appears to +succeed and the flag simply never persists. If a flag you set has no effect, +this is why. + +| Key | Meaning | +|---|---| +| `min`, `max` | numeric bounds | +| `enum` | `string[]`, the allowed values for `select` / `multiselect` | +| `targetSchema` | for `relation`: the slug of the collection/schema referenced | +| `many` | for `relation`: array of references instead of one. Absent/false = single | +| `group` | section or tab name; groups fields in the dashboard form | +| `searchable` | include this field in `--q`. When **no** field sets it, `q` spans all `text` fields | +| `filterable` | offer this field as a filter control in the dashboard | +| `column` | show as a column in the records table. When none set, the first four displayable fields are used | +| `columnOrder` | lower sorts first | + +## Worked examples + +A repeater with subfields: + + { + "name": "tags", + "type": "repeater", + "required": false, + "subfields": [ + { "name": "label", "type": "text", "required": true } + ] + } + +Record value: `{ "tags": [ { "label": "news" }, { "label": "featured" } ] }` + +A many-relation: + + { + "name": "authors", + "type": "relation", + "required": false, + "options": { "targetSchema": "people", "many": true } + } + +Record value: `{ "authors": ["ada-lovelace", "alan-turing"] }` +Fetch it inlined with `--populate authors` (or `--populate '*'`). + +A select: + + { + "name": "status", + "type": "select", + "required": true, + "options": { "enum": ["draft", "review", "live"], "filterable": true } + } + +Record value: `{ "status": "review" }` + +## Confirming a real shape + +These definitions come from `packages/shared-types/src/zod.ts` and +`services/datana/src/services/validate.ts` in `sawala-cloud-core`. The CLI and +MCP treat `fields[]` as opaque, so when in doubt fetch a live collection and +copy its shape: + + sawala datana collection get diff --git a/.agents/skills/sawala-kontena/SKILL.md b/.agents/skills/sawala-kontena/SKILL.md new file mode 100644 index 0000000..e2911b5 --- /dev/null +++ b/.agents/skills/sawala-kontena/SKILL.md @@ -0,0 +1,112 @@ +--- +name: sawala-kontena +description: Model and manage Kontena content schemas and entries from the Sawala CLI or MCP — create and change a schema, add a field, create and publish entries, work across locales. Use when working with a Kontena schema or entry, or when content needs a new field or a translation. +metadata: + sawala-cli-version: "0.13.0" +--- + +# Kontena — content schemas and entries + +Kontena is the editorial content store. A **schema** is a content model; an +**entry** is a piece of content shaped by it. Both live inside a project. + +Read `sawala-cli` first for scope, `--dry-run`, and `--yes` conventions. The +one that bites hardest here: **`update` is a PUT replacement.** + +Prefer the MCP tools (`sawala_kontena_*`, 12 of them) when available; the CLI +group is `sawala kontena` (13 subcommands). + +## Two things Datana does not have + +**Locales.** Almost every entry command takes `--locale `. An entry is +identified by `(schemaSlug, slug, locale)`, so the "same" article in `en` and +`id` is two entries. + +**Single-type vs collection schemas.** A *collection* schema holds many +entries with unique slugs per locale. A *single* schema holds exactly one +entry per locale — think "Homepage" or "Site Settings". The CLI fetches the +schema first and routes to the right endpoint automatically, so you address +both the same way; the behavioural difference is that **creating on a +single-type schema is an upsert**, not a 409, and that `entry delete` on one +**requires `--locale`**. + +## Schemas + + sawala kontena list # shortcut for `schema list` + sawala kontena schema list + sawala kontena schema get + sawala kontena schema create -f model.json + sawala kontena schema update -f model.json # PUT replacement + sawala kontena schema delete [-y] + +`schema get` accepts a ULID or a slug: it tries the ULID route first and, on a +404, lists schemas and matches by slug. **That fallback lists only the first +100 schemas**, so in a very large project a slug lookup can miss something that +exists — pass the ULID if a slug you expect reports `not found`. + +Unlike Datana, Kontena **does** expose `schema delete`. It is destructive and +requires `-y/--yes` or a TTY. Surface it to the user rather than adding the +flag yourself. + +### Adding a field — the operation agents get wrong + +`schema update` replaces the stored document. Sending only the new field +deletes every other field. Always pull, append, push: + + sawala kontena schema get article > /tmp/article.json + # append to .fields[] in /tmp/article.json, e.g. + # { "name": "subtitle", "type": "text", "required": false } + sawala kontena schema update article -f /tmp/article.json --dry-run + sawala kontena schema update article -f /tmp/article.json + +A field is `{ name, type, required }` plus optional `label`, `labels` +(per-locale), `unique`, `localized`, `hidden`, `private`, `default`, +`validation`, `options`, and `subfields`. `type` is one of `text`, `url`, +`richtext`, `markdown`, `number`, `boolean`, `date`, `relation`, `media`, +`file-private`, `blocks`, `component`, `json`, `select`, `multiselect`, +`repeater`. **`localized: true` is what makes a field vary per locale.** + +> `options` is validated by a non-passthrough schema: any key outside +> `min`, `max`, `enum`, `targetSchema`, `many`, `group`, `searchable`, +> `filterable`, `column`, `columnOrder` is **silently discarded on write**. +> If a flag you set has no effect, that is why. + +The field definition is shared byte-for-byte with Datana. If you need worked +JSON for `repeater`, `relation`, or `select`, the `sawala-datana` skill carries +it in full — load that skill, or just fetch a live schema and copy its shape +with `sawala kontena schema get `. + +## Entries + + sawala kontena entry list [--locale ] + sawala kontena entry get [--locale ] + sawala kontena entry create -f entry.json [--publish] + sawala kontena entry update -f entry.json [--publish] + sawala kontena entry publish + sawala kontena entry unpublish + sawala kontena entry delete [--locale ] [-y] + +`` is a ULID or a slug. + +### The entry body is an envelope — not the field values + +This is the opposite of Datana, and getting it backwards is a guaranteed 422. + + { + "locale": "en", // REQUIRED + "data": { "title": "Hello" }, // REQUIRED — the field values live here + "slug": "hello", // collection types only; derived from data if omitted + "status": "draft", // optional; draft unless --publish + "publishedAt": "2026-08-04T00:00:00Z" // optional, ISO 8601 + } + +The CLI sends this object as-is (setting `status` when `--publish` is passed). +Contrast with `sawala datana record create`, whose body is the bare field +values and is wrapped into `{ data, status }` for you. + +On a collection schema, `(slug, locale)` must be unique — a duplicate is a 409. +On a single schema, the same call upserts that locale's entry instead. + +`entry update` is a PUT replacement too: pull, edit, push. To change only the +lifecycle use `publish` / `unpublish`, which do not resend the body and so +cannot clobber field values. From ab9f6fb5fb54bdb4dd7e4b85a0d8489925a46964 Mon Sep 17 00:00:00 2001 From: Sutisna Date: Tue, 4 Aug 2026 13:04:29 +0200 Subject: [PATCH 3/6] feat(skills): sawala-tugasna, sawala-sebar, sawala-akuna MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tugasna: boards/statuses/items/comments/tags/timeline plus a backlog reference. Documents two departures from the content stores — updates are PATCH not PUT, and dates are epoch-ms numbers not ISO strings. Sebar: the CLI is inbound email config (org-scoped) plus broadcast campaigns (project-scoped) — not sender/message management. broadcast create SENDS with no undo, so it is documented as dry-run-then-approve. Akuna: connections and data residency. Isolation is effectively one-way, so the skill requires status -> dry-run -> explicit user approval. Also corrects sawala-cli's shared write conventions: PUT-replacement is a Kontena/Datana rule, not universal. --- .agents/skills/sawala-akuna/SKILL.md | 61 ++++++++++++ .agents/skills/sawala-cli/SKILL.md | 11 ++- .agents/skills/sawala-sebar/SKILL.md | 82 +++++++++++++++++ .agents/skills/sawala-tugasna/SKILL.md | 92 +++++++++++++++++++ .../sawala-tugasna/references/backlog.md | 58 ++++++++++++ 5 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 .agents/skills/sawala-akuna/SKILL.md create mode 100644 .agents/skills/sawala-sebar/SKILL.md create mode 100644 .agents/skills/sawala-tugasna/SKILL.md create mode 100644 .agents/skills/sawala-tugasna/references/backlog.md diff --git a/.agents/skills/sawala-akuna/SKILL.md b/.agents/skills/sawala-akuna/SKILL.md new file mode 100644 index 0000000..71ff660 --- /dev/null +++ b/.agents/skills/sawala-akuna/SKILL.md @@ -0,0 +1,61 @@ +--- +name: sawala-akuna +description: Inspect Akuna end-user identity connections and manage per-organization data residency from the Sawala CLI — list connections, check whether the org is on a dedicated database, and move it to isolated storage. Use when asked about end-user auth connections, data residency, dedicated databases, or isolating an organization's data. +metadata: + sawala-cli-version: "0.13.0" +--- + +# Akuna — connections and data residency + +Akuna is the end-user identity layer. A **connection** is one identity source +attached to an org. Connections are either **managed** (Sawala-hosted) or +**BYO** (bring-your-own). Only four subcommands, all **org-scoped**, no MCP +tools. + +Read `sawala-cli` first for scope and `--yes` conventions. + +## Read first + + sawala akuna connection list # id, mode, domain, storage, status + sawala akuna storage status + +`storage status` prints one of two shapes — `Shared storage. N BYO connection(s) +in the org.` or a dedicated-database summary. Both are plain sentences, not +JSON. + +## Isolation — read this before running anything + +Two commands move data onto a dedicated per-org D1 database: + + sawala akuna storage isolate [-y] [--dry-run] # the whole org + sawala akuna connection isolate [-y] [--dry-run] # one connection + +> **This is effectively one-way. There is no isolated → shared migration.** +> The CLI's own confirmation says so. It is idempotent and it provisions the +> database if needed, but it cannot be undone by re-running anything. + +**Never run either command to satisfy a vague request.** "Make our data more +secure", "set up the database", and "isolate this" are not authorisation to +relocate a tenant's data. The correct sequence is: + +1. Run `sawala akuna storage status` and show the user where they are now. +2. Run the isolate command with `--dry-run` and show them exactly what it posts. +3. State plainly that it cannot be reversed. +4. Let the **user** run the real command, or run it only on an explicit, + unambiguous instruction that names isolation. + +Do not pass `-y` on your own initiative. Without a TTY the command refuses +rather than prompting, and that refusal is the safety mechanism working. + +## Which one to use + +`storage isolate` is the primary model: it moves **all** the org's BYO +connections onto one dedicated database, and new BYO connections inherit it. +Prefer it. + +`connection isolate ` is the per-connection primitive underneath. Reach for +it only when the user has named a single connection. + +**Managed connections always stay on shared storage** and are unaffected by +either command. If `storage status` reports fewer isolated connections than the +org has in total, managed connections are the likely reason — not a failure. diff --git a/.agents/skills/sawala-cli/SKILL.md b/.agents/skills/sawala-cli/SKILL.md index 9737abb..c5ae84b 100644 --- a/.agents/skills/sawala-cli/SKILL.md +++ b/.agents/skills/sawala-cli/SKILL.md @@ -88,11 +88,16 @@ do not repeat them. - Bodies come from `-f, --file ` (`-` means stdin) or `-d, --data `. Passing both is an error. -- **`update` is a PUT replacement.** Adding one field means: `get` the current - document, append to the object you got back, and send the whole thing. Never - send a body containing only the new field — it deletes everything else. +- **`update` semantics differ per product — check before you write.** + - **Kontena and Datana: PUT replacement.** Adding one field means `get` the + current document, append to what you got back, and send the whole thing. + A body containing only the new field deletes everything else. + - **Tugasna: PATCH.** Send only the keys you want changed. + - When unsure, run the command with `--dry-run` and read the `method` in the + printed `wouldSend`. - Kontena and Datana have a draft/published lifecycle with explicit `publish` / `unpublish` verbs. Creating leaves a draft unless `--publish`. + Tugasna has no such lifecycle — an item's state is its status column. - Reads print pretty JSON. Lists print terse padded columns. - A non-zero exit means the message on stderr is the error. diff --git a/.agents/skills/sawala-sebar/SKILL.md b/.agents/skills/sawala-sebar/SKILL.md new file mode 100644 index 0000000..9ab4a88 --- /dev/null +++ b/.agents/skills/sawala-sebar/SKILL.md @@ -0,0 +1,82 @@ +--- +name: sawala-sebar +description: Configure inbound email and send broadcast campaigns through Sebar from the Sawala CLI — set and verify a custom inbound domain, provision friendly inbound addresses, and create/list/inspect broadcast campaigns. Use when setting up an email address to receive mail, checking why an inbound domain is unverified, or sending and tracking a bulk email campaign. +metadata: + sawala-cli-version: "0.13.0" +--- + +# Sebar — inbound email and broadcasts + +`sawala sebar` covers two unrelated halves, and they are scoped differently: + +- **`inbound`** — the org's custom domain for *receiving* mail, and the + friendly addresses on it. **Org-scoped.** +- **`broadcast`** — bulk outbound campaigns. **Project-scoped.** + +Ten subcommands, no MCP tools — shell out to the CLI. Read `sawala-cli` first +for scope and `--yes` conventions. + +Note what is *not* here: there is no command to send a single transactional +email, and no sender-signature management. Those are service-side concerns. +Do not invent `sawala sebar send`. + +## Inbound domain (org-scoped) + + sawala sebar inbound domain show + sawala sebar inbound domain set [--dry-run] + sawala sebar inbound domain verify + sawala sebar inbound domain remove [-y] + +`set` takes a **dedicated subdomain of at least three labels** — `inbox.acme.co.id`, +never the apex `acme.co.id`. It prints the MX record to publish. Publishing +that record is a manual DNS step outside the CLI. + +`verify` re-checks the MX against live DNS and updates the stored state. It is +the command to run after the DNS change propagates, and the one to re-run when +someone reports mail not arriving. `show` prints `No inbound domain set.` when +there is none — that is a plain sentence, not JSON, so do not try to parse it. + +The usual sequence: + + sawala sebar inbound domain set inbox.acme.co.id # prints the MX to publish + # … publish the MX record in DNS, wait for propagation … + sawala sebar inbound domain verify + sawala sebar inbound domain show # confirm the state flipped + +## Inbound addresses (org-scoped) + + sawala sebar inbound address list + sawala sebar inbound address add
[--dry-run] + sawala sebar inbound address remove
[-y] + +Friendly addresses on the verified inbound domain. Adding one before the +domain verifies will not work — check `domain show` first. + +## Broadcasts (project-scoped) + + sawala sebar broadcast create -f campaign.json [--dry-run] + sawala sebar broadcast list [--status queued|sending|completed|failed] + sawala sebar broadcast get + +Create body: + + { + "templateId": "…", + "name": "August newsletter", + "recipients": [ + { "email": "a@example.com", "name": "Ada", "variables": { "plan": "pro" } } + ] + } + +> **The template must be a broadcast-stream template.** A transactional +> template will be rejected. If `create` fails on the template, that is the +> first thing to check. + +**`create` sends.** There is no separate send step and no undo — the campaign +is queued the moment the call succeeds. Always run `--dry-run` first, show the +user the recipient count and the payload, and get explicit approval before the +real call. This is the single most consequential command in the group. + +`list` prints terse columns including a `delivered/total` progress figure and +fetches up to 100 campaigns. `get ` returns the counters plus the **first +page** of recipients — do not treat that page as the complete recipient list. diff --git a/.agents/skills/sawala-tugasna/SKILL.md b/.agents/skills/sawala-tugasna/SKILL.md new file mode 100644 index 0000000..30468ce --- /dev/null +++ b/.agents/skills/sawala-tugasna/SKILL.md @@ -0,0 +1,92 @@ +--- +name: sawala-tugasna +description: Manage Tugasna work tracking from the Sawala CLI — boards, statuses, items, comments, tags, the timeline, and the backlog. Use when creating or updating a board or task, moving an item between columns, reordering statuses, commenting on work, or working with backlog items and their placement. +metadata: + sawala-cli-version: "0.13.0" +--- + +# Tugasna — boards, items, backlog + +Tugasna is the work tracker. A **board** holds **statuses** (columns) which +hold **items**. A project also has a **backlog** of items that are not on any +board yet. All of it is project-scoped. + +`sawala tugasna` is the largest group in the CLI — 30 subcommands across +`board`, `status`, `item`, `backlog`, `comment`, `tag`, and `timeline`. There +are **no MCP tools for Tugasna**; shell out to the CLI. + +Read `sawala-cli` first for scope and `--yes` conventions. + +## Two things that differ from Kontena and Datana + +**`update` is a PATCH, not a PUT replacement.** Send only the keys you want to +change. There is no pull-append-push dance here, and sending a full document +is unnecessary (though harmless). + +**Dates are epoch-millisecond numbers, not ISO strings.** `startDate`, +`dueDate`, and `endDate` all take a number like `1785801600000`. Pass `null` +to clear one on update. An ISO string will be rejected or stored wrong. + +## Boards + + sawala tugasna board list [--archived] + sawala tugasna board get # includes statuses, fields, labels + sawala tugasna board create -d '{"name":"Q3 Launch"}' + sawala tugasna board update -d '{"color":"#ff0000"}' # PATCH + sawala tugasna board archive + sawala tugasna board unarchive + sawala tugasna board delete [-y] + +Create body: `{ name, description?, color?, startDate?, endDate? }`. Creating a +board **seeds default statuses**, so you rarely need to create columns by hand. + +`board list` shows active boards only; `--archived` shows archived ones +*instead*, not as well. An item someone "can't find" is often on an archived +board. + +> `board delete` removes the board **and everything on it**. Prefer `archive`, +> and always surface a delete to the user rather than passing `-y` yourself. + +## Statuses (columns) + + sawala tugasna status create -d '{"name":"In Review"}' + sawala tugasna status update -d '{"color":"#888"}' # PATCH + sawala tugasna status delete [-y] + sawala tugasna status reorder -d '{"statusIds":["s1","s2","s3"]}' + +`reorder` takes the complete list of status ids in the desired order. + +## Items + + sawala tugasna item list [--status ] [--assignee ] + sawala tugasna item get + sawala tugasna item create -f item.json + sawala tugasna item update -d '{"title":"…"}' # PATCH + sawala tugasna item move -d '{"statusId":"s2","position":0}' + sawala tugasna item delete [-y] + +Create body: `{ title, description?, statusId?, assignees?, startDate?, dueDate? }`. +Omit `statusId` and the item lands in the board's first column. + +`move` requires **both** `statusId` and `position`; `position` is a 0-based +index within the target column. Use it to reorder inside a column too — pass +the current `statusId` with a new `position`. + +## Comments, tags, timeline + + sawala tugasna comment list + sawala tugasna comment create -d '{"text":"Shipped."}' + sawala tugasna comment update -d '{"text":"…"}' # PATCH + sawala tugasna comment delete [-y] + sawala tugasna tag list + sawala tugasna timeline + +Reply to a comment by adding `{ "parentId": "" }` to the create body. +`tag list` is read-only — tags are defined in the dashboard. `timeline` shows +items carrying start/due dates. + +## Backlog + +Backlog items are project-level and belong to no board until placed. See +[the backlog model](references/backlog.md) for placement, sub-items, and the +difference between `item` and `backlog` commands addressing the same item. diff --git a/.agents/skills/sawala-tugasna/references/backlog.md b/.agents/skills/sawala-tugasna/references/backlog.md new file mode 100644 index 0000000..f0a092e --- /dev/null +++ b/.agents/skills/sawala-tugasna/references/backlog.md @@ -0,0 +1,58 @@ +# The Tugasna backlog + +A **backlog item** is a project-level item that sits on no board. Placing it +puts it into a board's column; unplacing returns it to the backlog. The item +keeps its id throughout — placement changes where it appears, not what it is. + +## Commands + + sawala tugasna backlog list # unplaced items only + sawala tugasna backlog create -d '{"title":"Investigate flaky test"}' + sawala tugasna backlog get # works for board items too + sawala tugasna backlog update -d '{…}' # PATCH + sawala tugasna backlog children # sub-items + sawala tugasna backlog place -d '{"boardId":"b1","statusId":"s1"}' + sawala tugasna backlog unplace + +Create body: `{ title, description?, parentId? }`. Passing `parentId` makes the +new item a **sub-item** of that item; `backlog children ` lists them. + +## `backlog get` vs `item get` + +Both address items, but they are scoped differently: + +- `backlog get ` is **project-scoped** and resolves any item, whether + it is on a board or in the backlog. Use it when you have an item id and do + not know where it lives. +- `item get ` is **board-scoped** and needs the board id. + +The same applies to `backlog update` versus `item update` — the backlog form +needs no board id. Prefer the backlog form when you are working from an item +id alone. + +## Placing + + sawala tugasna backlog place -d '{"boardId":"b1","statusId":"s1","position":0}' + +`boardId` and `statusId` are both required; `position` is optional and 0-based +(omit it to append). Get valid status ids from `sawala tugasna board get `, +which returns the board with its statuses. + +`unplace ` removes the item from its board and returns it to the +backlog. It does not delete anything — the item, its comments, and its +sub-items all survive. + +## A worked flow + + # 1. capture the work with no board decision yet + sawala tugasna backlog create -d '{"title":"Rate-limit the webhook"}' + # → prints the created item, including its id + + # 2. find the target column + sawala tugasna board get b_01J... # read .statuses[].id + + # 3. put it on the board, top of the column + sawala tugasna backlog place i_01J... -d '{"boardId":"b_01J...","statusId":"s_01J...","position":0}' + + # 4. later, move it along + sawala tugasna item move b_01J... i_01J... -d '{"statusId":"s_done","position":0}' From 204a353d026b10c4c13e162304920e0c599d06df Mon Sep 17 00:00:00 2001 From: Sutisna Date: Tue, 4 Aug 2026 13:06:51 +0200 Subject: [PATCH 4/6] feat(skills): kodena-deploy and sawala-ajena MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kodena-deploy: the three deploy shapes, the mandatory kodena.json, and the blank Content-Type trap scoped correctly — Kodena's default shim already carries an extension-map safety net, so only worker-bundle (OpenNext) deploys are exposed. Also records that run_worker_first is set by the service, so callers should not try to set it. sawala-ajena: pull -> edit -> validate -> push --check as the only safe workflow, the token-derived scoping that puts no projectId in the path, the per-step enabled switch for bisecting, and the secret-free round-trip. --- .agents/skills/kodena-deploy/SKILL.md | 117 ++++++++++++++++++++++++++ .agents/skills/sawala-ajena/SKILL.md | 111 ++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 .agents/skills/kodena-deploy/SKILL.md create mode 100644 .agents/skills/sawala-ajena/SKILL.md diff --git a/.agents/skills/kodena-deploy/SKILL.md b/.agents/skills/kodena-deploy/SKILL.md new file mode 100644 index 0000000..caa38d7 --- /dev/null +++ b/.agents/skills/kodena-deploy/SKILL.md @@ -0,0 +1,117 @@ +--- +name: kodena-deploy +description: Deploy and operate sites and workers on Kodena with the kodena CLI — static sites, worker bundles, OpenNext builds of Next.js, and single-file code deploys — plus scripts, assets, env vars, secrets, custom domains, and logs. Use when asked to deploy, redeploy, point a domain at a site, read deploy logs, or debug a site that deployed successfully but serves the wrong thing. +metadata: + sawala-cli-version: "0.11.0" +--- + +# Kodena — deploying and operating + +Kodena is the deploy target. Its CLI is `kodena`, a **separate binary** from +`sawala` with its own credentials under `~/.kodena/` (`KODENA_API_TOKEN`, +`KODENA_ORG`, `KODENA_PROJECT`). `@sawala/kodena-mcp` mirrors it with 21 tools. + +Unlike `sawala`, `kodena deploy` **does** accept per-command scope overrides: +`--org`, `--project`, `--token`, `--api-base`. Prefer those over changing +global state. + +## `kodena.json` is mandatory + +Every deploy needs a `kodena.json` in the current directory or a parent — +`--code` deploys included. Minimum content: + + { "slug": "" } + +Without it the command fails before any network call. `kodena.json` may also +carry `project`, which sits between `KODENA_PROJECT` and `~/.kodena/config` in +the project-resolution chain, and a `build` block (`command`, `runByDefault`, +`static`). + +## Pick the deploy shape first + +Choosing wrong is the most expensive mistake here. There are three: + +**Static site — `--static`.** Uploads a build output directory as +`kind:assets`, no worker of your own. Kodena installs a small shim worker and +pins compatibility date `2025-01-24`. Use for pure static output. + +**Worker bundle — the default.** Uploads your built worker. This is the shape +a Next.js app takes when built with OpenNext; add `--build` to run +`kodena.json`'s `build.command`, which defaults to +`npx @opennextjs/cloudflare build`. + +**Single module — `--code `.** Deploys one directly-authored Worker +source file as `kind:code`, skipping build and auto-detection. The source +round-trips, so it is readable back via `kodena env` and the dashboard editor. + +`--no-build` and `--no-static` force off whatever `kodena.json` turns on. +`--dry-run` runs everything up to the network call and prints a summary. + +## Static-deploy behaviour + +Kodena sets `html_handling: "auto-trailing-slash"` on the asset config. That +is what makes `/page.html` redirect to `/page` — a redirect, not a rewrite, so +anything asserting on the exact URL must expect the hop. A root `index.html` +is what makes `/` resolve; without one the site has no entry point. + +You do **not** need to set `run_worker_first` yourself. Kodena already sets it +for asset deploys — it is required for the shim's `Cache-Control` rewriting to +run at all, and the service handles it. + +## The blank `Content-Type` trap — worker bundles only + +Some assets are served from R2 with no `Content-Type`, which breaks favicons, +SVGs, and fonts in the browser. + +**Static (`--static`) deploys are already protected**: Kodena's default shim +carries a safety net that infers the type from the file extension for `ico`, +`png`, `jpg`, `jpeg`, `webp`, `svg`, `css`, `js`, `json`, `woff2`, `xml`, and +`txt`. + +**Worker-bundle deploys — including every OpenNext build — are not.** When you +supply your own worker, Kodena uses it instead of the shim and the safety net +goes with it. If a deployed Next.js site renders but its icons, fonts, or SVGs +do not load, this is the cause. The fix is to set the header in your own +worker's asset response path, mirroring the shim's extension map. + +## Debugging a deploy that "worked" + +Two first moves when the deploy succeeded but the site misbehaves: + + kodena logs # runtime logs + kodena script get # what is actually deployed + +**Scripts are project-scoped.** `kodena script list` and the MCP +`kodena_list_scripts` reflect the active project only, so "the script does not +exist" often means "wrong project", not "missing". Check with +`kodena project list` before concluding anything is gone. + +## The rest of the surface + + kodena script list|get|rename|rehydrate|rm + kodena asset list|get|patch|rebuild + kodena env list|set|unset + kodena secret put|list|rm + kodena domain set|status|rm + kodena logs / kodena logging + kodena template list + kodena slug check + kodena org list|use|handle kodena project list|use + +Prefer `kodena secret put` over `--secret KEY=value` at deploy time: it rotates +a secret with no rebuild. `--var` and `--secret` are both repeatable and take +`KEY=value`. + +`--compat-flag` accepts only `nodejs_compat` or `nodejs_als` and is repeatable — +pass the flag twice rather than a comma-separated string. `--compat-date` takes +`YYYY-MM-DD`. + +## Environment traps + +- **Wrangler needs Node v22.** The repo's `engines` allows v20 and the default + `node` often is v20. Fix: `export PATH=~/.nvm/versions/node/v22.19.0/bin:$PATH`. +- A Cloudflare **`Authentication error`** during deploy is usually an API-token + **scope gap**, not a wrong or expired token. Check the token's permissions + before reissuing it. +- `.id` domains are registered through the reseller **liqu.id**, not Cloudflare, + so a `.id` custom domain has a registrar step outside `kodena domain set`. diff --git a/.agents/skills/sawala-ajena/SKILL.md b/.agents/skills/sawala-ajena/SKILL.md new file mode 100644 index 0000000..d6662ec --- /dev/null +++ b/.agents/skills/sawala-ajena/SKILL.md @@ -0,0 +1,111 @@ +--- +name: sawala-ajena +description: Manage Ajena automations as code from the Sawala CLI — pull a flow to a file, edit it, validate it, and push it back; trigger runs and inspect step-by-step run traces. Use when asked to create, edit, review, or debug an Ajena flow or automation, or to work out why a flow run failed. +metadata: + sawala-cli-version: "0.13.0" +--- + +# Ajena FLOW — automations as code + +A **flow** is an automation: a trigger plus a dependency graph of steps. The +CLI treats it as one JSON artifact, a **FlowDocument**, that you pull, edit, +and push. `sawala ajena` has 11 subcommands and no MCP tools. + +## Scoping is different here + +**No project id appears in any Ajena URL.** Ajena derives `{org, project}` +from the CLI token's own scope, which the gateway resolves and forwards. Two +consequences worth internalising: + +- Scope cannot be widened by anything in the body you push. A flow id + belonging to another org resolves to a plain 404. +- **Do not copy a path shape from Kontena or Datana**, which do put a project + ULID in the path. Ajena paths are `/cli/ajena/api/admin/flows` and nothing + more. + +## The only safe workflow + + # 1. pull to a file + sawala ajena flow pull -o flow.json + + # 2. edit flow.json + + # 3. validate before you touch the server's copy + sawala ajena flow validate -f flow.json + + # 4. push, re-validating server-side and refusing if invalid + sawala ajena flow push -f flow.json --check + +`validate` exits non-zero on an invalid document, which is what makes it usable +as a CI gate. `push --check` validates first and refuses to write. **Always +pass `--check`** — a push without it will happily store a broken flow. Add +`--dry-run` to see the payload without writing at all. + +Validation errors come back as `{ stepId, path, message }`, so they point at +the exact step and config key. Fix the named path rather than guessing. + +## The other commands + + sawala ajena list # shortcut for `flow list` + sawala ajena flow list + sawala ajena flow get # FlowDocument to stdout + sawala ajena flow pull [-o ] + sawala ajena flow create -f flow.json [--check] [--dry-run] + sawala ajena flow push -f flow.json [--check] [--dry-run] + sawala ajena flow delete [-y] + sawala ajena flow validate -f flow.json + sawala ajena flow run [-f input.json] + sawala ajena flow runs [--flow ] [--status ] + sawala ajena flow run-get + +`pull` writes to a path (or stdout with `-o -`); `get` always prints to stdout. +Use `pull` when you intend to edit. + +## The FlowDocument + + { + "schemaVersion": 1, + "flowId": "…", + "name": "…", + "description": "…", + "enabled": true, + "trigger": { "type": "…", "channel": "…", "expr": "…" }, + "steps": [ + { "id": "s1", "kind": "…", "name": "…", + "dependsOn": [], "config": { }, "enabled": true } + ] + } + +`schemaVersion` declares the contract the document was written against. **The +server accepts an absent or older version and refuses a newer one** — so never +hand-increment it hoping for new behaviour. + +Note the two independent switches: `FlowDocument.enabled` turns the whole flow +off; `FlowStep.enabled` turns one step off. A step with `enabled: false` is +skipped at run time **with its config retained**, so re-enabling is lossless +and it round-trips through pull/push unchanged. That makes it the right tool +for bisecting a failing flow — disable steps, push, run, narrow down. + +## Secrets in a FlowDocument + +The document is **secret-free by design**. An `extract_document` step's PDF +passwords are stored encrypted and never exported; a pulled document shows +only `passwordCount` / `hasPassword`. + +Pushing a document with those password fields absent **keeps** the stored +passwords. So the ordinary edit-and-push round-trip neither leaks a secret nor +destroys one — you do not need to do anything special to preserve them. +Changing a password is an explicit opt-in: put `"passwords": ["new"]` in the +pushed JSON. Never write a real password into a file you leave lying around, +and never echo one. + +## Debugging a failed run + + sawala ajena flow runs --flow --status failed + sawala ajena flow run-get + +`runs` lists newest first; `--status` takes `queued`, `running`, `succeeded`, +`failed`, or `cancelled`. `run-get` returns the run **including its full +step-by-step trace** — read that before theorising from the flow document. +Trigger a fresh run with `sawala ajena flow run `, optionally with +`-f input.json` as the trigger input; it prints the new `runId`. From 1288fa296bce1bab96e04ef8011495124c619737 Mon Sep 17 00:00:00 2001 From: Sutisna Date: Tue, 4 Aug 2026 13:10:30 +0200 Subject: [PATCH 5/6] feat(skills): sawala-cli-dev skill and the skills drift guard scripts/check-skills-coverage.mjs fails CI when a CLI command group or MCP tool name appears in no skill, when a skill breaks the Agent Skills spec (name pattern, name/dir match, description length, 500-line body, dangling links), or when a skill contains a credential-shaped string. Wired as npm run check:skills and added to ci.yml. Adding the tool names surfaced 53 undocumented tools; each is now in a CLI-to-MCP mapping table in the owning skill rather than a bare name list. --- .agents/skills/kodena-deploy/SKILL.md | 20 +++ .agents/skills/sawala-cli-dev/SKILL.md | 105 +++++++++++++ .agents/skills/sawala-cli/SKILL.md | 11 +- .agents/skills/sawala-datana/SKILL.md | 24 +++ .agents/skills/sawala-kontena/SKILL.md | 27 ++++ .github/workflows/ci.yml | 1 + package.json | 3 +- scripts/check-skills-coverage.mjs | 209 +++++++++++++++++++++++++ 8 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/sawala-cli-dev/SKILL.md create mode 100644 scripts/check-skills-coverage.mjs diff --git a/.agents/skills/kodena-deploy/SKILL.md b/.agents/skills/kodena-deploy/SKILL.md index caa38d7..3991c6f 100644 --- a/.agents/skills/kodena-deploy/SKILL.md +++ b/.agents/skills/kodena-deploy/SKILL.md @@ -106,6 +106,26 @@ a secret with no rebuild. `--var` and `--secret` are both repeatable and take pass the flag twice rather than a comma-separated string. `--compat-date` takes `YYYY-MM-DD`. +## MCP equivalents + +`@sawala/kodena-mcp` exposes 21 tools. Prefer them for reads and for scripted +mutation; the CLI still owns `deploy` itself. + +| Area | MCP tools | +|---|---| +| identity, scope | `kodena_whoami`, `kodena_list_orgs`, `kodena_list_projects`, `kodena_get_org_handle`, `kodena_set_org_handle` | +| scripts | `kodena_list_scripts`, `kodena_get_script`, `kodena_create_script`, `kodena_update_script`, `kodena_deploy_script`, `kodena_rehydrate_script`, `kodena_delete_script` | +| assets | `kodena_get_asset`, `kodena_patch_assets`, `kodena_rebuild_assets_manifest` | +| domains | `kodena_set_custom_domain`, `kodena_get_custom_domain_status`, `kodena_remove_custom_domain` | +| logs, secrets, slugs | `kodena_get_script_logs`, `kodena_list_secrets`, `kodena_check_slug_available` | + +`kodena_list_secrets` returns names only — secret values are never readable +back, by design. `kodena_check_slug_available` before `kodena_create_script` +saves a round-trip on a taken slug. + +Session commands (`kodena login`, `kodena logout`) have no MCP equivalent: +authentication is a terminal action by design. + ## Environment traps - **Wrangler needs Node v22.** The repo's `engines` allows v20 and the default diff --git a/.agents/skills/sawala-cli-dev/SKILL.md b/.agents/skills/sawala-cli-dev/SKILL.md new file mode 100644 index 0000000..8d1a579 --- /dev/null +++ b/.agents/skills/sawala-cli-dev/SKILL.md @@ -0,0 +1,105 @@ +--- +name: sawala-cli-dev +description: Contribute to the Sawala CLI monorepo — add a command to @sawala/cli, mirror it as an MCP tool, follow the house conventions, and ship it. Use when editing anything in the sawala-tech/cli repository, adding or changing a CLI command or MCP tool, writing command tests, or preparing a release. +metadata: + sawala-cli-version: "0.13.0" +--- + +# Contributing to the Sawala CLI monorepo + +npm workspaces under `packages/*`. Node >= 20, but **use v22 for anything +touching `wrangler`**: `export PATH=~/.nvm/versions/node/v22.19.0/bin:$PATH`. + +## A PR without a changeset ships nothing + +This is the single most consequential fact in this repository. Published +packages are versioned by [changesets](https://github.com/changesets/changesets). +**A PR that changes a package's behaviour without a changeset never bumps the +version, so no user ever receives the change.** + +Add one file per change at `.changeset/.md`: + + --- + "@sawala/cli": minor + --- + + One paragraph, user-facing: what someone can now do that they could not + before, and anything surprising about it. This becomes the CHANGELOG entry, + so write it for a user of the CLI, not a reviewer of the diff. + +`patch` = bug fix, no new surface. `minor` = new command, flag, or behaviour. +`major` = a break (removed/renamed command, changed default). List **every** +package affected, each with its own bump — a change spanning `@sawala/cli` and +`@sawala/mcp` names both. + +Skip a changeset only for changes with no published effect: tests, docs, CI, +`AGENTS.md`, or the skills in `.agents/`. + +Never hand-edit the generated "Version Packages" PR. + +## Which package + +| Path | Package | Holds | +|---|---|---| +| `packages/sawala` | `@sawala/cli` | the multi-service platform CLI (`sawala …`) | +| `packages/kodena` | `@sawala/kodena` | the deploy CLI (`kodena …`) | +| `packages/sawala-auth` | `@sawala/auth` | shared credentials, context, `apiFetch` | +| `packages/sawala-mcp` | `@sawala/mcp` | MCP server mirroring `@sawala/cli` | +| `packages/kodena-mcp` | `@sawala/kodena-mcp` | MCP server mirroring `@sawala/kodena` | + +New platform CRUD goes in **`packages/sawala`**, next to the `datana`/`kontena` +precedent. `sawala-cloud-core`'s `AGENTS.md` says to build it in +`packages/kodena` — **that wording is stale.** `packages/kodena` is the deploy +CLI only. + +## Adding a command + +`src/commands/datana.ts` is the reference implementation. Copy its shape. + +- A new group is `src/commands/.ts` exporting + `createCommand()`, registered in `src/cli.ts`. +- Use `apiFetch(ctx, '/cli//')`, `loadContext`, + `requireActiveOrg`, and `requireActiveProject` — plus + `requireActiveProjectId` when the service takes a project ULID in the path. +- Bodies via `-f/--file` (`-` = stdin) or `-d/--data`, resolved by + `resolveInputPayload` from `src/lib/io`. +- `--dry-run` prints `{ wouldSend: { method, body } }` and writes nothing. +- Destructive verbs take `-y/--yes`, else `confirmOrThrow`, which refuses + outright with no TTY. +- Reads print pretty JSON; lists print terse padded columns. +- Errors: `throw new Error(msg)`. `src/cli.ts` maps a throw to stderr + exit 1 — + that is what makes `validate`-style commands usable as CI gates. + +Check the service before copying a path shape: Kontena and Datana resolve a +project ULID into the path, while Ajena derives scope from the CLI token and +takes none. + +Note that `@sawala/cli` does **not** wire the `--org`/`--project`/`--token` +flags that `CliOptions` supports — every call site is `loadContext(SAWALA_BRAND)` +with no options, even though `requireActiveProject`'s message advertises +`--project`. `kodena deploy` is the one command that does pass them. Wiring +them in `@sawala/cli` would be a genuine improvement. + +## Tests + +Command tests live in `packages//test/`, stub `fetch`, and assert the +exact method, path, and body the CLI would send — see `test/datana.test.ts` +and `test/ajena.test.ts`. A new command should assert its path, its +`--dry-run`, and its confirmation and exit-code behaviour. + +## Before opening a PR + + npm run typecheck && npm run test && npm run check:skills + +`check:skills` runs `scripts/check-skills-coverage.mjs`, which fails when a CLI +command group or MCP tool name appears in no skill under `.agents/skills/`, when +a skill breaks the Agent Skills spec, or when a skill contains something +credential-shaped. **A new command or MCP tool must also be documented in a +skill** — see `.agents/skills/README.md` for the layout and why it is +`.agents/` rather than `.claude/`. + +The upstream reference validator (`skills-ref validate `, a Python package +from the Agent Skills project) is a useful occasional cross-check, but CI does +not depend on it — its authors describe it as demonstration-quality. + +Then check: **did you add a changeset?** diff --git a/.agents/skills/sawala-cli/SKILL.md b/.agents/skills/sawala-cli/SKILL.md index c5ae84b..e9ccd57 100644 --- a/.agents/skills/sawala-cli/SKILL.md +++ b/.agents/skills/sawala-cli/SKILL.md @@ -73,9 +73,18 @@ Two products have no deeper skill because they hold no traps: - **Formulir** — `sawala formulir form list|get `, `sawala formulir submission list |get `. Project-scoped. **Read-only from the CLI: there is no create, update, or - delete.** Do not invent one. + delete.** Do not invent one. MCP: `sawala_formulir_list_forms`, + `sawala_formulir_get_form`, `sawala_formulir_list_submissions`, + `sawala_formulir_get_submission`. - **Berkasna** — `sawala berkasna asset list|get ` (id is a ULID). **Org-scoped, not project-scoped**, and likewise **read-only from the CLI.** + MCP: `sawala_berkasna_list_assets`, `sawala_berkasna_get_asset`. + +Session and scope commands: `sawala login`, `sawala logout`, `sawala whoami`, +`sawala org list|use`, `sawala project list|use`. Their MCP counterparts are +`sawala_whoami`, `sawala_list_orgs`, and `sawala_list_projects` — all +read-only, so an agent can orient itself over MCP alone but **cannot** change +scope or log in that way. That asymmetry is deliberate. Discover the rest at runtime rather than trusting this page if it looks stale: `sawala --help`, `sawala --help`, `kodena --help`, and for MCP the diff --git a/.agents/skills/sawala-datana/SKILL.md b/.agents/skills/sawala-datana/SKILL.md index 6b8adee..2912ef0 100644 --- a/.agents/skills/sawala-datana/SKILL.md +++ b/.agents/skills/sawala-datana/SKILL.md @@ -124,6 +124,30 @@ matches nothing at all. 25, but the CLI actually sends `limit=100` when the flag is omitted. Pass it explicitly if the number matters. +## MCP equivalents + +Prefer these over shelling out — the input is schema-validated and the output +is structured JSON. + +| CLI | MCP tool | +|---|---| +| `collection list` | `sawala_datana_list_collections` | +| `collection get` | `sawala_datana_get_collection` | +| `collection create` | `sawala_datana_create_collection` | +| `collection update` | `sawala_datana_update_collection` | +| `record list` | `sawala_datana_list_records` | +| `record get` | `sawala_datana_get_record` | +| `record create` | `sawala_datana_create_record` | +| `record update` | `sawala_datana_update_record` | +| `record publish` | `sawala_datana_publish_record` | +| `record unpublish` | `sawala_datana_unpublish_record` | +| `record delete` | `sawala_datana_delete_record` | + +The pipeline commands have **no** MCP equivalent — use the CLI for those. + +Note the MCP tools have no `--dry-run`. When a write is one you generated +rather than the user dictated, prefer the CLI so you can show the payload first. + ## Pipeline — the append-only analytical plane sawala datana pipeline create -f model.json diff --git a/.agents/skills/sawala-kontena/SKILL.md b/.agents/skills/sawala-kontena/SKILL.md index e2911b5..57ecaa7 100644 --- a/.agents/skills/sawala-kontena/SKILL.md +++ b/.agents/skills/sawala-kontena/SKILL.md @@ -110,3 +110,30 @@ On a single schema, the same call upserts that locale's entry instead. `entry update` is a PUT replacement too: pull, edit, push. To change only the lifecycle use `publish` / `unpublish`, which do not resend the body and so cannot clobber field values. + +## MCP equivalents + +Prefer these over shelling out — the input is schema-validated and the output +is structured JSON. + +| CLI | MCP tool | +|---|---| +| `schema list` | `sawala_kontena_list_schemas` | +| `schema get` | `sawala_kontena_get_schema` | +| `schema create` | `sawala_kontena_create_schema` | +| `schema update` | `sawala_kontena_update_schema` | +| `schema delete` | `sawala_kontena_delete_schema` | +| `entry list` | `sawala_kontena_list_entries` | +| `entry get` | `sawala_kontena_get_entry` | +| `entry create` | `sawala_kontena_create_entry` | +| `entry update` | `sawala_kontena_update_entry` | +| `entry publish` | `sawala_kontena_publish_entry` | +| `entry unpublish` | `sawala_kontena_unpublish_entry` | +| `entry delete` | `sawala_kontena_delete_entry` | + +`sawala_kontena_create_entry` fetches the schema first and routes single vs +collection for you, so you address both the same way — the same convenience +the CLI provides. + +The MCP tools have no `--dry-run`. When a write is one you generated rather +than the user dictated, prefer the CLI so you can show the payload first. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f52a294..96a6897 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,4 @@ jobs: - run: npm run typecheck - run: npm run test - run: npm run build + - run: npm run check:skills diff --git a/package.json b/package.json index 1456466..887d115 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test": "npm --workspaces --if-present run test", "typecheck": "npm --workspaces --if-present run typecheck", "lint": "npm --workspaces --if-present run lint", - "release": "changeset publish" + "release": "changeset publish", + "check:skills": "node scripts/check-skills-coverage.mjs" }, "devDependencies": { "@changesets/cli": "^2.27.0", diff --git a/scripts/check-skills-coverage.mjs b/scripts/check-skills-coverage.mjs new file mode 100644 index 0000000..89e39fb --- /dev/null +++ b/scripts/check-skills-coverage.mjs @@ -0,0 +1,209 @@ +#!/usr/bin/env node +// Guards the Agent Skills in .agents/skills/ against drifting behind the code. +// +// Three checks, all failing the build: +// 1. Coverage — every MCP tool name and every CLI command group is mentioned +// somewhere in the skills corpus. +// 2. Spec — each skill satisfies the Agent Skills spec (agentskills.io): +// name pattern, name === directory, description length, body +// size, and no dangling relative links. +// 3. Secrets — no credential-shaped string is committed in a skill. +// +// Coverage deliberately tests *mention*, not correctness: no script can judge +// whether a description is accurate, and a stricter rule would just be gamed by +// pasting names into a footer. Mention is enough to force the author to open the +// skill and think. + +import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs' +import { join, dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const SKILLS_DIR = join(ROOT, '.agents', 'skills') + +const MCP_PACKAGES = ['sawala-mcp', 'kodena-mcp'] +const CLI_PACKAGES = ['sawala', 'kodena'] + +// Agent Skills spec limits. +const NAME_RE = /^(?!-)(?!.*--)[a-z0-9-]{1,64}(? + statSync(join(SKILLS_DIR, d)).isDirectory(), +) + +const corpusFiles = walk(SKILLS_DIR).filter((f) => f.endsWith('.md')) +const corpus = corpusFiles.map((f) => readFileSync(f, 'utf8')).join('\n') + +// ── 1. coverage ───────────────────────────────────────────────────────────── +// MCP tool names are declared on the exported ToolDefinition as +// `name: 'sawala_…'` / `name: 'kodena_…'`. The prefix keeps us from matching +// the `name` keys that appear inside inputSchema property maps. +const toolNames = [] +for (const pkg of MCP_PACKAGES) { + const dir = join(ROOT, 'packages', pkg, 'src', 'tools') + if (!existsSync(dir)) continue + for (const file of readdirSync(dir)) { + if (!file.endsWith('.ts') || file === 'index.ts' || file === 'types.ts') continue + const src = readFileSync(join(dir, file), 'utf8') + for (const m of src.matchAll(/name:\s*'((?:sawala|kodena)_[a-z0-9_]+)'/g)) { + toolNames.push({ name: m[1], pkg }) + } + } +} + +// Command groups are the filenames under each CLI package's src/commands/. +const commandGroups = [] +for (const pkg of CLI_PACKAGES) { + const dir = join(ROOT, 'packages', pkg, 'src', 'commands') + if (!existsSync(dir)) continue + for (const file of readdirSync(dir)) { + if (!file.endsWith('.ts')) continue + commandGroups.push({ name: file.replace(/\.ts$/, ''), pkg }) + } +} + +function suggest(name, pkg) { + const stem = name.replace(/^(sawala|kodena)_/, '').split('_')[0] + const guesses = [`sawala-${stem}`, `${stem}-deploy`, stem] + const hit = guesses.find((g) => skillDirs.includes(g)) + if (hit) return `.agents/skills/${hit}/SKILL.md` + return pkg.startsWith('kodena') + ? '.agents/skills/kodena-deploy/SKILL.md' + : '.agents/skills/sawala-cli/SKILL.md' +} + +const missingTools = toolNames.filter((t) => !corpus.includes(t.name)) +const missingGroups = commandGroups.filter((g) => { + // A group counts as covered when its name appears as a word anywhere. + return !new RegExp(`\\b${g.name}\\b`, 'i').test(corpus) +}) + +if (missingTools.length || missingGroups.length) { + fail.push('MISSING from all skills:') + headings++ + for (const t of missingTools) { + fail.push(` ${t.name.padEnd(38)} (suggest: ${suggest(t.name, t.pkg)})`) + } + for (const g of missingGroups) { + fail.push(` ${(g.pkg + ' ' + g.name).padEnd(38)} (suggest: ${suggest(g.name, g.pkg)})`) + } +} + +// ── 2. spec conformance ───────────────────────────────────────────────────── +for (const dir of skillDirs) { + const skillMd = join(SKILLS_DIR, dir, 'SKILL.md') + if (!existsSync(skillMd)) { + fail.push(`${dir}: no SKILL.md (the filename must be exactly SKILL.md)`) + continue + } + const raw = readFileSync(skillMd, 'utf8') + const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/) + if (!m) { + fail.push(`${dir}/SKILL.md: no YAML frontmatter delimited by --- lines`) + continue + } + const [, front, body] = m + + const nameMatch = front.match(/^name:\s*(.+)$/m) + const descMatch = front.match(/^description:\s*([\s\S]*?)(?=\n[a-z-]+:|$)/m) + + if (!nameMatch) fail.push(`${dir}/SKILL.md: frontmatter has no \`name\``) + else { + const name = nameMatch[1].trim() + if (!NAME_RE.test(name)) { + fail.push( + `${dir}/SKILL.md: name '${name}' breaks the spec (1-64 chars, [a-z0-9-], no leading/trailing/doubled hyphen)`, + ) + } + if (name !== dir) { + fail.push(`${dir}/SKILL.md: name '${name}' must match its directory '${dir}'`) + } + } + + if (!descMatch) fail.push(`${dir}/SKILL.md: frontmatter has no \`description\``) + else { + const desc = descMatch[1].trim() + if (desc.length === 0) fail.push(`${dir}/SKILL.md: description is empty`) + if (desc.length > MAX_DESCRIPTION) { + fail.push(`${dir}/SKILL.md: description is ${desc.length} chars (max ${MAX_DESCRIPTION})`) + } + } + + const bodyLines = body.split('\n').length + if (bodyLines > MAX_BODY_LINES) { + fail.push( + `${dir}/SKILL.md: body is ${bodyLines} lines (max ${MAX_BODY_LINES}) — move detail into references/`, + ) + } + + // Relative markdown links must resolve. A dangling references/ pointer is a + // skill that dead-ends mid-task. + for (const link of raw.matchAll(/\]\((?!https?:|#)([^)]+)\)/g)) { + const target = link[1].split('#')[0] + if (!target) continue + if (target.startsWith('../')) { + fail.push( + `${dir}/SKILL.md: link '${target}' points outside the skill — skills install individually, so it would dangle`, + ) + continue + } + if (!existsSync(resolve(join(SKILLS_DIR, dir), target))) { + fail.push(`${dir}/SKILL.md: dangling link '${target}'`) + } + } +} + +// ── 3. secret scan ────────────────────────────────────────────────────────── +for (const file of corpusFiles) { + const src = readFileSync(file, 'utf8') + for (const { label, re } of SECRET_RES) { + if (re.test(src)) { + fail.push(`${file.replace(ROOT + '/', '')}: looks like it contains a ${label}`) + } + } +} + +// ── report ────────────────────────────────────────────────────────────────── +if (fail.length) { + console.error(fail.join('\n')) + console.error( + `\n${fail.length - headings} problem(s). Add the name to a skill, fix the spec violation, or remove the secret.`, + ) + process.exit(1) +} + +console.log( + `skills coverage: ${toolNames.length}/${toolNames.length} MCP tools, ` + + `${commandGroups.length}/${commandGroups.length} command groups mentioned across ${skillDirs.length} skills.`, +) From 702fb28f5356891758f327c796ae4fc190def737 Mon Sep 17 00:00:00 2001 From: Sutisna Date: Tue, 4 Aug 2026 13:14:25 +0200 Subject: [PATCH 6/6] =?UTF-8?q?feat(sawala):=20sawala=20skills=20install?= =?UTF-8?q?=20=E2=80=94=20ship=20the=20Agent=20Skills=20to=20any=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocates the eight customer-facing skills to packages/sawala/skills/ so they ride the npm tarball, symlinked back into .agents/skills/ so contributors and customers read the same bytes. Adds sawala skills list|install|uninstall. --target maps to each vendor's discovery directory (agents/codex -> .agents/skills, claude -> .claude/skills, copilot -> .github/skills or ~/.copilot/skills, all -> three); default is agents, which Codex and Copilot both read. When only .agents is written and a .claude/ exists, it suggests --target all or a symlink rather than acting. Requested names are matched against the bundled set and never joined into a path; destinations outside cwd/home need --force; existing folders are never overwritten without --force. 16 tests including four negative cases. --- .agents/skills/kodena-deploy | 1 + .agents/skills/sawala-ajena | 1 + .agents/skills/sawala-akuna | 1 + .agents/skills/sawala-cli | 1 + .agents/skills/sawala-datana | 1 + .agents/skills/sawala-kontena | 1 + .agents/skills/sawala-sebar | 1 + .agents/skills/sawala-tugasna | 1 + .changeset/agent-skills-install.md | 9 + packages/sawala/package.json | 1 + .../sawala}/skills/kodena-deploy/SKILL.md | 0 .../sawala}/skills/sawala-ajena/SKILL.md | 0 .../sawala}/skills/sawala-akuna/SKILL.md | 0 .../sawala}/skills/sawala-cli/SKILL.md | 0 .../sawala}/skills/sawala-datana/SKILL.md | 0 .../sawala-datana/references/field-types.md | 0 .../sawala}/skills/sawala-kontena/SKILL.md | 0 .../sawala}/skills/sawala-sebar/SKILL.md | 0 .../sawala}/skills/sawala-tugasna/SKILL.md | 0 .../sawala-tugasna/references/backlog.md | 0 packages/sawala/src/cli.ts | 2 + packages/sawala/src/commands/skills.ts | 144 +++++++++++ packages/sawala/src/lib/skills.ts | 242 ++++++++++++++++++ packages/sawala/test/skills.test.ts | 181 +++++++++++++ 24 files changed, 587 insertions(+) create mode 120000 .agents/skills/kodena-deploy create mode 120000 .agents/skills/sawala-ajena create mode 120000 .agents/skills/sawala-akuna create mode 120000 .agents/skills/sawala-cli create mode 120000 .agents/skills/sawala-datana create mode 120000 .agents/skills/sawala-kontena create mode 120000 .agents/skills/sawala-sebar create mode 120000 .agents/skills/sawala-tugasna create mode 100644 .changeset/agent-skills-install.md rename {.agents => packages/sawala}/skills/kodena-deploy/SKILL.md (100%) rename {.agents => packages/sawala}/skills/sawala-ajena/SKILL.md (100%) rename {.agents => packages/sawala}/skills/sawala-akuna/SKILL.md (100%) rename {.agents => packages/sawala}/skills/sawala-cli/SKILL.md (100%) rename {.agents => packages/sawala}/skills/sawala-datana/SKILL.md (100%) rename {.agents => packages/sawala}/skills/sawala-datana/references/field-types.md (100%) rename {.agents => packages/sawala}/skills/sawala-kontena/SKILL.md (100%) rename {.agents => packages/sawala}/skills/sawala-sebar/SKILL.md (100%) rename {.agents => packages/sawala}/skills/sawala-tugasna/SKILL.md (100%) rename {.agents => packages/sawala}/skills/sawala-tugasna/references/backlog.md (100%) create mode 100644 packages/sawala/src/commands/skills.ts create mode 100644 packages/sawala/src/lib/skills.ts create mode 100644 packages/sawala/test/skills.test.ts diff --git a/.agents/skills/kodena-deploy b/.agents/skills/kodena-deploy new file mode 120000 index 0000000..c872850 --- /dev/null +++ b/.agents/skills/kodena-deploy @@ -0,0 +1 @@ +../../packages/sawala/skills/kodena-deploy \ No newline at end of file diff --git a/.agents/skills/sawala-ajena b/.agents/skills/sawala-ajena new file mode 120000 index 0000000..fb022c6 --- /dev/null +++ b/.agents/skills/sawala-ajena @@ -0,0 +1 @@ +../../packages/sawala/skills/sawala-ajena \ No newline at end of file diff --git a/.agents/skills/sawala-akuna b/.agents/skills/sawala-akuna new file mode 120000 index 0000000..774887a --- /dev/null +++ b/.agents/skills/sawala-akuna @@ -0,0 +1 @@ +../../packages/sawala/skills/sawala-akuna \ No newline at end of file diff --git a/.agents/skills/sawala-cli b/.agents/skills/sawala-cli new file mode 120000 index 0000000..eca3aca --- /dev/null +++ b/.agents/skills/sawala-cli @@ -0,0 +1 @@ +../../packages/sawala/skills/sawala-cli \ No newline at end of file diff --git a/.agents/skills/sawala-datana b/.agents/skills/sawala-datana new file mode 120000 index 0000000..a952484 --- /dev/null +++ b/.agents/skills/sawala-datana @@ -0,0 +1 @@ +../../packages/sawala/skills/sawala-datana \ No newline at end of file diff --git a/.agents/skills/sawala-kontena b/.agents/skills/sawala-kontena new file mode 120000 index 0000000..6cc2bc9 --- /dev/null +++ b/.agents/skills/sawala-kontena @@ -0,0 +1 @@ +../../packages/sawala/skills/sawala-kontena \ No newline at end of file diff --git a/.agents/skills/sawala-sebar b/.agents/skills/sawala-sebar new file mode 120000 index 0000000..ebf1878 --- /dev/null +++ b/.agents/skills/sawala-sebar @@ -0,0 +1 @@ +../../packages/sawala/skills/sawala-sebar \ No newline at end of file diff --git a/.agents/skills/sawala-tugasna b/.agents/skills/sawala-tugasna new file mode 120000 index 0000000..a7a1a80 --- /dev/null +++ b/.agents/skills/sawala-tugasna @@ -0,0 +1 @@ +../../packages/sawala/skills/sawala-tugasna \ No newline at end of file diff --git a/.changeset/agent-skills-install.md b/.changeset/agent-skills-install.md new file mode 100644 index 0000000..138f6a0 --- /dev/null +++ b/.changeset/agent-skills-install.md @@ -0,0 +1,9 @@ +--- +"@sawala/cli": minor +--- + +New `sawala skills` command: install the Sawala Agent Skills into your AI coding agent so it knows how to drive these tools correctly. + +`sawala skills install` copies eight skills — orientation plus one per product (Kontena, Datana, Tugasna, Sebar, Akuna, Ajena, Kodena) — into your project. They carry the things that are not in `--help`: that a collection or schema `update` is a PUT replacement so adding a field means pull-append-push, that a Datana boolean filter silently returns zero rows, that Tugasna dates are epoch-millisecond numbers, that `sebar broadcast create` sends immediately with no undo, and that `akuna isolate` is effectively one-way. + +Skills are an open cross-vendor format, so one install serves Claude Code, GitHub Copilot, and OpenAI Codex. The default target is `.agents/skills/`, which Codex and Copilot both read; pass `--target all` to also write `.claude/skills/` and `.github/skills/`, or `--global` to install into your home directory. `sawala skills list` shows what is bundled and `sawala skills uninstall` removes them again. Installing never overwrites an existing skill folder without `--force`, and `--dry-run` prints what it would write. diff --git a/packages/sawala/package.json b/packages/sawala/package.json index d0da61c..a10bc5e 100644 --- a/packages/sawala/package.json +++ b/packages/sawala/package.json @@ -25,6 +25,7 @@ }, "files": [ "dist", + "skills", "LICENSE", "README.md" ], diff --git a/.agents/skills/kodena-deploy/SKILL.md b/packages/sawala/skills/kodena-deploy/SKILL.md similarity index 100% rename from .agents/skills/kodena-deploy/SKILL.md rename to packages/sawala/skills/kodena-deploy/SKILL.md diff --git a/.agents/skills/sawala-ajena/SKILL.md b/packages/sawala/skills/sawala-ajena/SKILL.md similarity index 100% rename from .agents/skills/sawala-ajena/SKILL.md rename to packages/sawala/skills/sawala-ajena/SKILL.md diff --git a/.agents/skills/sawala-akuna/SKILL.md b/packages/sawala/skills/sawala-akuna/SKILL.md similarity index 100% rename from .agents/skills/sawala-akuna/SKILL.md rename to packages/sawala/skills/sawala-akuna/SKILL.md diff --git a/.agents/skills/sawala-cli/SKILL.md b/packages/sawala/skills/sawala-cli/SKILL.md similarity index 100% rename from .agents/skills/sawala-cli/SKILL.md rename to packages/sawala/skills/sawala-cli/SKILL.md diff --git a/.agents/skills/sawala-datana/SKILL.md b/packages/sawala/skills/sawala-datana/SKILL.md similarity index 100% rename from .agents/skills/sawala-datana/SKILL.md rename to packages/sawala/skills/sawala-datana/SKILL.md diff --git a/.agents/skills/sawala-datana/references/field-types.md b/packages/sawala/skills/sawala-datana/references/field-types.md similarity index 100% rename from .agents/skills/sawala-datana/references/field-types.md rename to packages/sawala/skills/sawala-datana/references/field-types.md diff --git a/.agents/skills/sawala-kontena/SKILL.md b/packages/sawala/skills/sawala-kontena/SKILL.md similarity index 100% rename from .agents/skills/sawala-kontena/SKILL.md rename to packages/sawala/skills/sawala-kontena/SKILL.md diff --git a/.agents/skills/sawala-sebar/SKILL.md b/packages/sawala/skills/sawala-sebar/SKILL.md similarity index 100% rename from .agents/skills/sawala-sebar/SKILL.md rename to packages/sawala/skills/sawala-sebar/SKILL.md diff --git a/.agents/skills/sawala-tugasna/SKILL.md b/packages/sawala/skills/sawala-tugasna/SKILL.md similarity index 100% rename from .agents/skills/sawala-tugasna/SKILL.md rename to packages/sawala/skills/sawala-tugasna/SKILL.md diff --git a/.agents/skills/sawala-tugasna/references/backlog.md b/packages/sawala/skills/sawala-tugasna/references/backlog.md similarity index 100% rename from .agents/skills/sawala-tugasna/references/backlog.md rename to packages/sawala/skills/sawala-tugasna/references/backlog.md diff --git a/packages/sawala/src/cli.ts b/packages/sawala/src/cli.ts index 5098729..9b99165 100644 --- a/packages/sawala/src/cli.ts +++ b/packages/sawala/src/cli.ts @@ -11,6 +11,7 @@ import { createLogoutCommand } from './commands/logout' import { createOrgCommand } from './commands/org' import { createProjectCommand } from './commands/project' import { createSebarCommand } from './commands/sebar' +import { createSkillsCommand } from './commands/skills' import { createTugasnaCommand } from './commands/tugasna' import { createWhoamiCommand } from './commands/whoami' @@ -36,6 +37,7 @@ export function createProgram(): Command { program.addCommand(createSebarCommand()) program.addCommand(createTugasnaCommand()) program.addCommand(createAkunaCommand()) + program.addCommand(createSkillsCommand()) return program } diff --git a/packages/sawala/src/commands/skills.ts b/packages/sawala/src/commands/skills.ts new file mode 100644 index 0000000..9937f0d --- /dev/null +++ b/packages/sawala/src/commands/skills.ts @@ -0,0 +1,144 @@ +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { Command } from 'commander' +import { + assertSafeDest, + installSkills, + listBundledSkills, + resolveTargetDirs, + SKILL_TARGETS, + uninstallSkills, + type SkillTarget, +} from '../lib/skills' + +function collectTarget(val: string, prev: SkillTarget[] = []): SkillTarget[] { + if (!(SKILL_TARGETS as readonly string[]).includes(val)) { + throw new Error(`Unknown target '${val}'. Valid targets: ${SKILL_TARGETS.join(', ')}.`) + } + prev.push(val as SkillTarget) + return prev +} + +interface DestOptions { + target?: SkillTarget[] + global?: boolean + dir?: string + force?: boolean +} + +function destsFor(opts: DestOptions): string[] { + const cwd = process.cwd() + const home = homedir() + + if (opts.dir) { + if (opts.target?.length || opts.global) { + throw new Error('Pass either --dir or --target/--global, not both.') + } + const dest = resolve(opts.dir) + if (!opts.force) assertSafeDest(dest, cwd, home) + return [dest] + } + + const targets = opts.target?.length ? opts.target : (['agents'] as SkillTarget[]) + const dests = resolveTargetDirs({ + targets, + ...(opts.global ? { global: true } : {}), + cwd, + home, + }) + for (const d of dests) if (!opts.force) assertSafeDest(d, cwd, home) + return dests +} + +export function createSkillsCommand(): Command { + const skills = new Command('skills').description( + 'Install the Sawala Agent Skills into your agent so it knows how to drive these tools.', + ) + + skills + .command('list') + .description('List the skills bundled with this CLI.') + .action(async () => { + const all = await listBundledSkills() + const width = Math.max(...all.map((s) => s.name.length)) + for (const s of all) { + const summary = s.description.length > 96 ? s.description.slice(0, 93) + '…' : s.description + process.stdout.write(`${s.name.padEnd(width)} ${summary}\n`) + } + }) + + skills + .command('install [names...]') + .description( + 'Copy skills into your agent\'s skills directory. Defaults to all skills, ' + + 'target `agents` (.agents/skills — read by Codex and Copilot).', + ) + .option( + '--target ', + `Where to write: ${SKILL_TARGETS.join(' | ')}. Repeatable. Default: agents.`, + collectTarget, + [], + ) + .option('--global', 'Install into your home directory instead of the current directory.') + .option('--dir ', 'Write to this exact directory instead of a target mapping.') + .option('--force', 'Overwrite existing skill folders.') + .option('--dry-run', 'Print what would be written without writing.') + .action(async (names: string[], opts: DestOptions & { dryRun?: boolean }) => { + const dests = destsFor(opts) + const result = await installSkills({ + ...(names.length ? { names } : {}), + dests, + ...(opts.force ? { force: true } : {}), + ...(opts.dryRun ? { dryRun: true } : {}), + }) + + for (const p of result.written) { + process.stdout.write(`${opts.dryRun ? 'would write' : 'wrote'} ${join(p, 'SKILL.md')}\n`) + } + if (result.skipped.length > 0) { + for (const p of result.skipped) { + process.stderr.write(`exists, skipped: ${p}\n`) + } + throw new Error( + `${result.skipped.length} skill(s) already installed. Re-run with --force to overwrite.`, + ) + } + + // `.agents/skills` covers Codex and Copilot but not Claude Code. Suggest; + // never act — writing a second copy is the drift problem in miniature. + const wroteAgentsOnly = + !opts.dir && dests.length === 1 && dests[0]?.endsWith(join('.agents', 'skills')) + if (wroteAgentsOnly && !opts.dryRun && existsSync(join(process.cwd(), '.claude'))) { + process.stdout.write( + '\nThis directory has a .claude/ — Claude Code reads .claude/skills, not .agents/skills.\n' + + 'Either re-run with --target all, or link them:\n' + + ' ln -s ../.agents/skills .claude/skills\n', + ) + } + }) + + skills + .command('uninstall ') + .description('Remove previously installed skills.') + .option( + '--target ', + `Where to remove from: ${SKILL_TARGETS.join(' | ')}. Repeatable. Default: agents.`, + collectTarget, + [], + ) + .option('--global', 'Operate on your home directory instead of the current directory.') + .option('--dir ', 'Operate on this exact directory.') + .option('--force', 'Allow a directory outside the cwd/home.') + .action(async (names: string[], opts: DestOptions) => { + const dests = destsFor(opts) + const { removed } = await uninstallSkills({ names, dests }) + if (removed.length === 0) { + process.stdout.write('Nothing to remove.\n') + return + } + for (const p of removed) process.stdout.write(`removed ${p}\n`) + }) + + return skills +} diff --git a/packages/sawala/src/lib/skills.ts b/packages/sawala/src/lib/skills.ts new file mode 100644 index 0000000..1e9e211 --- /dev/null +++ b/packages/sawala/src/lib/skills.ts @@ -0,0 +1,242 @@ +import { existsSync } from 'node:fs' +import { cp, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join, resolve, sep } from 'node:path' + +/** + * Which agent's skills directory to write to. + * + * The SKILL.md format is a cross-vendor standard, but the discovery directory + * is not — and the directories only partially overlap: + * + * Codex .agents/skills (and NOTHING else — not .codex, not .claude) + * Copilot .agents/skills | .github/skills | .claude/skills + * Claude Code .claude/skills + * + * `agents` is therefore the default: it is the one directory Codex and Copilot + * both read. `codex` is accepted as a synonym so a user who types what they + * mean gets what they meant instead of an error. + */ +export type SkillTarget = 'agents' | 'claude' | 'copilot' | 'codex' | 'all' + +export const SKILL_TARGETS: readonly SkillTarget[] = [ + 'agents', + 'claude', + 'copilot', + 'codex', + 'all', +] + +export interface BundledSkill { + /** Directory name; equals the `name:` in the skill's frontmatter. */ + name: string + /** Parsed from frontmatter. */ + description: string + /** Absolute path inside the installed package. */ + dir: string +} + +/** + * Where the bundled skills live at runtime. + * + * The published bundle is a single CommonJS file at `dist/cli.js`, so + * `__dirname` is `/dist` and the skills sit one level up. The env + * override exists so tests can point at a fixture without touching the real + * package. + */ +export function bundledSkillsDir(): string { + const override = process.env['SAWALA_SKILLS_DIR'] + if (override && override.length > 0) return resolve(override) + + // Published: __dirname is /dist, so skills sit one level up. + // From source (tests, npm link): __dirname is /src/lib, so they sit + // two levels up. Prefer the published layout and fall back, so the error + // message names the location a real install would use. + const published = resolve(__dirname, '..', 'skills') + if (existsSync(published)) return published + const fromSource = resolve(__dirname, '..', '..', 'skills') + if (existsSync(fromSource)) return fromSource + return published +} + +/** Read `name` and `description` out of a SKILL.md frontmatter block. */ +function parseFrontmatter(raw: string): { name?: string; description?: string } { + const m = raw.match(/^---\n([\s\S]*?)\n---/) + if (!m) return {} + const front = m[1] ?? '' + const name = front.match(/^name:\s*(.+)$/m)?.[1]?.trim() + // A description may wrap onto continuation lines; stop at the next top-level key. + const description = front + .match(/^description:\s*([\s\S]*?)(?=\n[a-z-]+:|$)/m)?.[1] + ?.trim() + .replace(/\s+/g, ' ') + return { ...(name ? { name } : {}), ...(description ? { description } : {}) } +} + +export async function listBundledSkills(): Promise { + const root = bundledSkillsDir() + let entries: string[] + try { + entries = await readdir(root) + } catch { + throw new Error( + `No bundled skills found at ${root}. This build of @sawala/cli looks incomplete — reinstall the package.`, + ) + } + + const skills: BundledSkill[] = [] + for (const entry of entries.sort()) { + const dir = join(root, entry) + if (!(await stat(dir)).isDirectory()) continue + let raw: string + try { + raw = await readFile(join(dir, 'SKILL.md'), 'utf8') + } catch { + continue + } + const { name, description } = parseFrontmatter(raw) + skills.push({ name: name ?? entry, description: description ?? '', dir }) + } + return skills +} + +/** + * Map targets to the absolute skills directories they write to. + * + * This is the single place the vendor table is encoded, so a vendor changing + * its discovery path is a one-line change with a failing test to prove it. + */ +export function resolveTargetDirs(opts: { + targets: SkillTarget[] + global?: boolean + cwd: string + home: string +}): string[] { + const { targets, global: isGlobal, cwd, home } = opts + const base = isGlobal ? home : cwd + + const dirs = new Set() + for (const t of targets) { + switch (t) { + case 'agents': + case 'codex': + dirs.add(join(base, '.agents', 'skills')) + break + case 'claude': + dirs.add(join(base, '.claude', 'skills')) + break + case 'copilot': + // Copilot reads .github/skills in a repo, but ~/.copilot/skills globally. + dirs.add(isGlobal ? join(home, '.copilot', 'skills') : join(cwd, '.github', 'skills')) + break + case 'all': + dirs.add(join(base, '.agents', 'skills')) + dirs.add(join(base, '.claude', 'skills')) + dirs.add(isGlobal ? join(home, '.copilot', 'skills') : join(cwd, '.github', 'skills')) + break + default: { + const bad: never = t + throw new Error( + `Unknown target '${String(bad)}'. Valid targets: ${SKILL_TARGETS.join(', ')}.`, + ) + } + } + } + return [...dirs] +} + +/** True when `child` is inside `parent` (or is `parent`). */ +function isInside(parent: string, child: string): boolean { + const p = resolve(parent) + const c = resolve(child) + return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep) +} + +/** + * Reject a destination outside both the working directory and the home + * directory. Writing agent instructions into an arbitrary path is not + * something to do by accident. + */ +export function assertSafeDest(dest: string, cwd: string, home: string): void { + if (isInside(cwd, dest) || isInside(home, dest)) return + throw new Error( + `Refusing to write outside your working directory or home directory: ${resolve(dest)}. ` + + 'Pass --force if you really mean it.', + ) +} + +/** Resolve requested names against the bundled set. Never joins input into a path. */ +async function selectSkills(names: string[] | undefined): Promise { + const all = await listBundledSkills() + if (!names || names.length === 0) return all + + const byName = new Map(all.map((s) => [s.name, s])) + const chosen: BundledSkill[] = [] + for (const requested of names) { + const hit = byName.get(requested) + if (!hit) { + throw new Error( + `Unknown skill '${requested}'. Available: ${all.map((s) => s.name).join(', ')}.`, + ) + } + chosen.push(hit) + } + return chosen +} + +export async function installSkills(opts: { + names?: string[] + dests: string[] + force?: boolean + dryRun?: boolean +}): Promise<{ written: string[]; skipped: string[] }> { + const chosen = await selectSkills(opts.names) + const written: string[] = [] + const skipped: string[] = [] + + for (const dest of opts.dests) { + for (const skill of chosen) { + const target = join(dest, skill.name) + const exists = await stat(target).then( + () => true, + () => false, + ) + if (exists && !opts.force) { + skipped.push(target) + continue + } + if (opts.dryRun) { + written.push(target) + continue + } + await mkdir(dest, { recursive: true }) + if (exists) await rm(target, { recursive: true, force: true }) + await cp(skill.dir, target, { recursive: true }) + written.push(target) + } + } + return { written, skipped } +} + +export async function uninstallSkills(opts: { + names: string[] + dests: string[] +}): Promise<{ removed: string[] }> { + const chosen = await selectSkills(opts.names) + const removed: string[] = [] + for (const dest of opts.dests) { + for (const skill of chosen) { + const target = join(dest, skill.name) + const exists = await stat(target).then( + () => true, + () => false, + ) + if (!exists) continue + await rm(target, { recursive: true, force: true }) + removed.push(target) + } + } + return { removed } +} + +export const SKILLS_HOME = homedir diff --git a/packages/sawala/test/skills.test.ts b/packages/sawala/test/skills.test.ts new file mode 100644 index 0000000..db7bc34 --- /dev/null +++ b/packages/sawala/test/skills.test.ts @@ -0,0 +1,181 @@ +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs' +import { tmpdir, homedir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + assertSafeDest, + installSkills, + listBundledSkills, + resolveTargetDirs, + uninstallSkills, +} from '../src/lib/skills' + +let fixture: string +let sandbox: string + +beforeEach(() => { + // A fake bundled-skills directory, so the tests never touch the real package. + fixture = mkdtempSync(join(tmpdir(), 'sawala-skills-fixture-')) + for (const name of ['alpha-skill', 'beta-skill']) { + mkdirSync(join(fixture, name), { recursive: true }) + writeFileSync( + join(fixture, name, 'SKILL.md'), + `---\nname: ${name}\ndescription: Fixture skill ${name}.\n---\n\nBody.\n`, + ) + } + process.env['SAWALA_SKILLS_DIR'] = fixture + sandbox = mkdtempSync(join(tmpdir(), 'sawala-skills-sandbox-')) +}) + +afterEach(() => { + delete process.env['SAWALA_SKILLS_DIR'] +}) + +describe('listBundledSkills', () => { + it('reads name and description from frontmatter', async () => { + const all = await listBundledSkills() + expect(all.map((s) => s.name)).toEqual(['alpha-skill', 'beta-skill']) + expect(all[0]?.description).toBe('Fixture skill alpha-skill.') + }) +}) + +describe('resolveTargetDirs', () => { + const cwd = '/repo' + const home = '/home/dev' + + it('maps agents and codex to the same .agents/skills', () => { + expect(resolveTargetDirs({ targets: ['agents'], cwd, home })).toEqual(['/repo/.agents/skills']) + expect(resolveTargetDirs({ targets: ['codex'], cwd, home })).toEqual(['/repo/.agents/skills']) + }) + + it('maps claude and copilot to their own project directories', () => { + expect(resolveTargetDirs({ targets: ['claude'], cwd, home })).toEqual(['/repo/.claude/skills']) + expect(resolveTargetDirs({ targets: ['copilot'], cwd, home })).toEqual(['/repo/.github/skills']) + }) + + it('uses home-directory locations under --global, with copilot at ~/.copilot', () => { + expect(resolveTargetDirs({ targets: ['agents'], global: true, cwd, home })).toEqual([ + '/home/dev/.agents/skills', + ]) + expect(resolveTargetDirs({ targets: ['copilot'], global: true, cwd, home })).toEqual([ + '/home/dev/.copilot/skills', + ]) + }) + + it('expands `all` to three directories and de-duplicates overlapping targets', () => { + expect(resolveTargetDirs({ targets: ['all'], cwd, home })).toEqual([ + '/repo/.agents/skills', + '/repo/.claude/skills', + '/repo/.github/skills', + ]) + // agents and codex collapse to one entry. + expect(resolveTargetDirs({ targets: ['agents', 'codex'], cwd, home })).toHaveLength(1) + }) + + it('never returns a path outside cwd or home — for every target', () => { + for (const t of ['agents', 'claude', 'copilot', 'codex', 'all'] as const) { + for (const isGlobal of [false, true]) { + for (const dir of resolveTargetDirs({ targets: [t], global: isGlobal, cwd, home })) { + expect(dir.startsWith(cwd) || dir.startsWith(home)).toBe(true) + } + } + } + }) +}) + +describe('installSkills', () => { + it('installs all bundled skills when no names are given', async () => { + const dest = join(sandbox, '.agents', 'skills') + const { written } = await installSkills({ dests: [dest] }) + expect(written).toHaveLength(2) + expect(existsSync(join(dest, 'alpha-skill', 'SKILL.md'))).toBe(true) + }) + + it('writes nothing under --dry-run', async () => { + const dest = join(sandbox, '.agents', 'skills') + const { written } = await installSkills({ dests: [dest], dryRun: true }) + expect(written).toHaveLength(2) + expect(existsSync(dest)).toBe(false) + }) + + // NEGATIVE TEST 1 — an unknown name is rejected and nothing is written. + it('rejects a name that is not a bundled skill, and writes nothing', async () => { + const dest = join(sandbox, '.agents', 'skills') + await expect( + installSkills({ names: ['../../../etc/cron.d'], dests: [dest] }), + ).rejects.toThrow(/Unknown skill/) + expect(existsSync(dest)).toBe(false) + }) + + // NEGATIVE TEST 2 — an existing folder is never silently clobbered. + it('refuses to overwrite without --force and leaves the file byte-identical', async () => { + const dest = join(sandbox, '.agents', 'skills') + mkdirSync(join(dest, 'alpha-skill'), { recursive: true }) + const mine = join(dest, 'alpha-skill', 'SKILL.md') + writeFileSync(mine, 'MY OWN SKILL') + + const { written, skipped } = await installSkills({ names: ['alpha-skill'], dests: [dest] }) + expect(written).toHaveLength(0) + expect(skipped).toEqual([join(dest, 'alpha-skill')]) + expect(readFileSync(mine, 'utf8')).toBe('MY OWN SKILL') + + await installSkills({ names: ['alpha-skill'], dests: [dest], force: true }) + expect(readFileSync(mine, 'utf8')).toContain('name: alpha-skill') + }) +}) + +// NEGATIVE TEST 3 — a destination outside cwd and home is refused. +describe('assertSafeDest', () => { + it('refuses a path outside both the working directory and home', () => { + expect(() => assertSafeDest('/etc/cron.d', '/repo', '/home/dev')).toThrow( + /Refusing to write outside/, + ) + }) + + it('allows paths inside either', () => { + expect(() => assertSafeDest('/repo/.agents/skills', '/repo', '/home/dev')).not.toThrow() + expect(() => assertSafeDest('/home/dev/.claude/skills', '/repo', '/home/dev')).not.toThrow() + }) + + it('is not fooled by a sibling with a shared prefix', () => { + expect(() => assertSafeDest('/repo-evil/skills', '/repo', '/home/dev')).toThrow() + }) +}) + +describe('uninstallSkills', () => { + it('removes only bundled skill names, leaving anything else alone', async () => { + const dest = join(sandbox, '.agents', 'skills') + await installSkills({ dests: [dest] }) + mkdirSync(join(dest, 'my-own-skill'), { recursive: true }) + writeFileSync(join(dest, 'my-own-skill', 'SKILL.md'), 'mine') + + const { removed } = await uninstallSkills({ names: ['alpha-skill'], dests: [dest] }) + expect(removed).toEqual([join(dest, 'alpha-skill')]) + expect(existsSync(join(dest, 'beta-skill'))).toBe(true) + expect(existsSync(join(dest, 'my-own-skill'))).toBe(true) + }) + + // NEGATIVE TEST 4 — uninstall cannot be pointed at an arbitrary name either. + it('rejects an unknown name rather than deleting the path', async () => { + const dest = join(sandbox, '.agents', 'skills') + await expect(uninstallSkills({ names: ['my-own-skill'], dests: [dest] })).rejects.toThrow( + /Unknown skill/, + ) + }) +}) + +describe('the real bundled skills', () => { + it('every shipped skill has a name matching its directory', async () => { + delete process.env['SAWALA_SKILLS_DIR'] + const all = await listBundledSkills() + expect(all.length).toBeGreaterThan(0) + for (const s of all) { + expect(s.dir.endsWith(s.name)).toBe(true) + expect(s.description.length).toBeGreaterThan(0) + expect(s.description.length).toBeLessThanOrEqual(1024) + } + // sawala-cli-dev is internal and must never be published. + expect(all.map((s) => s.name)).not.toContain('sawala-cli-dev') + expect(homedir()).toBeTruthy() + }) +})