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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(bash -n sync.sh)",
"Bash(./sync.sh --check)"
]
}
}
147 changes: 147 additions & 0 deletions .claude/skills/seclai-changelog/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
---
name: seclai-changelog
description: Write or update CHANGELOG.md in a Seclai SDK repo (seclai-python, seclai-javascript, seclai-go, seclai-csharp, seclai-cli, seclai-mcp) using the Common Changelog format. Use when adding a changelog entry, preparing a release, backfilling history from version tags, or finishing an OpenAPI spec sync in one of these repos.
---

# Seclai changelog entries

All six Seclai SDK repos keep a root `CHANGELOG.md` in [Common Changelog](https://common-changelog.org) format. This skill covers writing a new entry and backfilling from tags.

## Format rules

- File starts with `# Changelog`, then releases sorted latest-first.
- Release heading: `## [1.4.0] - 2026-07-25` — semver without a `v`, ISO date.
- **No `Unreleased` section.** Entries are written in the PR that ships them, under the version that PR will become (see "Determine the version").
- Change groups are third-level headings, only these four, always in this order:
`### Changed`, `### Added`, `### Removed`, `### Fixed`.
- A group heading is followed by an unordered list and nothing else.
- Entry form: `- Change ([ref](url))`. Imperative present tense, self-describing — it must read correctly without its group heading. "Support CentOS", never "Support of CentOS" or "Added support".
- Breaking changes take a `**Breaking:**` prefix and sort first within their group. Otherwise sort by importance.
- A release may open with a one-sentence italic notice instead of, or before, its groups — used for first releases (`_Initial release._`) and no-op version bumps.
- Version links are reference-style at the bottom: `[1.4.0]: https://github.com/seclai/<repo>/releases/tag/1.4.0` (tags carry no `v` prefix).
- Authors are omitted — these are effectively single-contributor repos.

## Determine the version

Do not guess the next version. Releases are cut by `seclai/github-tag-action` in `.github/workflows/main-build.yaml`, which reads the **merge commit message**:

- contains `#major` → major bump
- contains `#minor` → minor bump
- otherwise → `DEFAULT_BUMP: patch`

So a PR that adds endpoints must say `#minor` in its title/merge commit, or the heading you write will not match the tag that gets cut. Confirm the intended bump with the user when it isn't stated, and flag the mismatch risk if the PR title lacks the keyword.

Check `git tag --sort=-v:refname | head -1` for the current latest, then apply the bump.

## Derive entries from diffs, not from release notes

`gh release view` bodies are `--generate-notes` output — just "PR #N by @author". They are useless as entry text. Commit subjects like "2026 05 22 api sync" are equally useless. Always read the actual diff.

Extract the public API delta for a range. Per repo:

| Repo | Path | Pattern |
| --- | --- | --- |
| seclai-javascript | `src/client.ts` | `async ([a-zA-Z_][a-zA-Z0-9_]*)\(` |
| seclai-python | `seclai/seclai.py` | `^ (async )?def ([a-z][a-z0-9_]*)` |
| seclai-go | `*.go` | `^func \(c \*Client\) ([A-Z][A-Za-z0-9]*)\(` |
| seclai-csharp | `src/Seclai/SeclaiClient.cs` | `public (async )?[A-Za-z<>,? ]+ ([A-Z][A-Za-z0-9]*)\(` |
| seclai-cli | `src/commands/` | one file or subcommand per feature |
| seclai-mcp | `src/` | registered tool names |

```bash
# methods added between two tags
git diff PREV TAG -- src/ | grep "^+" | grep -oE '<pattern>' | sort -u
# and removed
git diff PREV TAG -- src/ | grep "^-" | grep -oE '<pattern>' | sort -u
```

A name in **both** lists was modified, not removed — check the signature diff before writing a `Removed` entry. Renames and reordering produce false positives constantly.

Also diff the type aliases (`src/types.ts`, `seclai/models`, etc.) and the bundled `openapi/seclai.openapi.json` — new schemas often mean new public types worth an entry even when no method changed.

## Classify

- New method, option, type export, or capability → **Added**
- Changed signature, default, accepted type, or behavior of something that already worked → **Changed**
- Deleted public surface → **Removed**
- It was broken and now works → **Fixed**

Judgment calls that have come up:

- A wrong default host or a wrong request path is **Fixed** — requests were failing — not Changed.
- Making a required parameter optional (via overload) is **Changed**, not Added.
- Exporting a type that should already have been exported is **Fixed**.
- Adding an endpoint to the bundled spec without a client method is worth its own entry; say so plainly rather than implying the method exists.
- A version bump with no code change gets a notice, not a group: `_Stable release. No functional changes since 0.0.1._`

## Write

Reference the PR when there is one (`([#9](https://github.com/seclai/<repo>/pull/9))`), otherwise the short commit SHA (`([`36bff73`](https://github.com/seclai/<repo>/commit/36bff73))`). For a PR not yet opened, omit the reference — never invent a number.

Group related additions into one entry when they ship as a unit (e.g. eight email-domain methods), and give standalone capabilities their own line. Aim for entries a user scanning for "what changed for me" can act on.

## Check the surface before you describe it

The changelog is the last gate before a release, so use the entries you are
writing as a prompt to spot-check the public surface they describe. Fix these, or
raise them, before they ship — each one is far cheaper now than after consumers
depend on it.

**Return the shape the spec declares.** Look up the endpoint's 2xx response schema
and mirror it:

| Spec response | Return type |
| --- | --- |
| `$ref` to a schema | that aliased type — never `unknown` |
| `type: object`, `additionalProperties: true` | `Record<string, unknown>` |
| `additionalProperties: {type: T}` | `Record<string, T>` |
| `type: array` | `T[]` / `unknown[]` — **not** `Record` |

A hand-written wrapper returning `unknown` where the generator had a real type
available is a bug, not a style choice.

**Widening `unknown` is breaking.** Changing a released method from `unknown` to
`Record<string, unknown>` breaks consumer code that does `result as SomeInterface`
— TypeScript rejects it with TS2352 and demands `as unknown as SomeInterface`.
That makes it a `#major`, never a patch or minor. New methods have no consumers
yet, so type them correctly from the start rather than inheriting a neighbour's
`unknown`.

**Server-defaulted request fields must be optional.** openapi-typescript emits any
property carrying a `default` as **required**, even when the spec leaves it out of
`required`. Callers then have to pass a value the server would have defaulted.
Wrap the generated request in an input type:

```ts
export type AddEmailDomainInput = Pick<AddEmailDomainRequest, "kind" | "value"> &
Partial<Omit<AddEmailDomainRequest, "kind" | "value">>;
```

See `AddEmailDomainInput` and `CreateExperimentInput` in `src/types.ts`.

**Compile the doc examples.** `npm run typecheck` covers `src/` and `tests/`, not
README or changelog snippets. Paste any example you write into a scratch `.ts`
that imports from `../src/index` and compile it — that is how the
`delegated`-is-required bug above was found, after the snippet had already shipped
in a PR.

## Validate

Run the bundled checker from the repo root before declaring done:

```bash
python3 .claude/skills/seclai-changelog/validate.py CHANGELOG.md
```

That path holds in every SDK repo — this skill is vendored there from [`seclai/sdk-tools`](https://github.com/seclai/sdk-tools). When working inside `sdk-tools` itself, the canonical copy is `skills/seclai-changelog/validate.py`.

It verifies heading format, descending version order, group names and ordering, `Breaking:` sorting, absence of an `Unreleased` section, and that every release has exactly one matching link definition. It exits non-zero on error, so the same invocation works as a CI gate.

Also confirm `CHANGELOG.md` is in the published artifact list — `files` in `package.json` for the JS/CLI/MCP repos, the packaging config for the others.

## Backfilling from tags

1. `git tag --sort=-v:refname` and `gh release list` for versions and dates. Use the GitHub release published date (UTC) as the entry date.
2. Walk consecutive tag pairs oldest-first, extracting the API delta for each as above.
3. Map each range to its PR via `gh release view <tag> --json body`, which does at least carry the PR number reliably.
4. The earliest release gets `_Initial release._`.
130 changes: 130 additions & 0 deletions .claude/skills/seclai-changelog/validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Validate a Common Changelog file as used by the Seclai SDK repos.

Usage: python3 validate.py [CHANGELOG.md]

Exits non-zero and prints one line per problem. Warnings do not affect the
exit code.
"""
import re
import sys

GROUPS = ["Changed", "Added", "Removed", "Fixed"]
RELEASE_RE = re.compile(r"^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})\s*$")
GROUP_RE = re.compile(r"^### (.+?)\s*$")
LINKDEF_RE = re.compile(r"^\[([^\]]+)\]:\s+(\S+)\s*$")
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")


def semver_key(v):
m = SEMVER_RE.match(v)
return tuple(int(g) for g in m.groups()) if m else (-1, -1, -1)


def main():
path = sys.argv[1] if len(sys.argv) > 1 else "CHANGELOG.md"
try:
lines = open(path, encoding="utf-8").read().split("\n")
except OSError as e:
print(f"error: cannot read {path}: {e}")
return 1

errors, warnings = [], []
releases, linkdefs = [], []
cur = None
seen_groups = []
group_entries = []
in_group = None

def close_group():
if in_group is None:
return
if not group_entries:
errors.append(f"{cur}: '### {in_group}' has no entries")
breaking = [i for i, e in enumerate(group_entries) if e.startswith("**Breaking:**")]
if breaking and breaking != list(range(len(breaking))):
errors.append(f"{cur}: breaking changes must sort first under '### {in_group}'")

if not lines or lines[0].strip() != "# Changelog":
errors.append("file must start with '# Changelog'")

for n, line in enumerate(lines, 1):
if line.strip().lower().startswith("## [unreleased") or line.strip().lower() == "## unreleased":
errors.append(f"line {n}: Common Changelog has no Unreleased section")
continue

m = RELEASE_RE.match(line)
if m:
close_group()
cur, seen_groups, in_group, group_entries = m.group(1), [], None, []
if not SEMVER_RE.match(cur):
errors.append(f"line {n}: '{cur}' is not a bare semver version")
releases.append(cur)
continue

if line.startswith("## "):
errors.append(f"line {n}: malformed release heading: {line.strip()!r}")
continue

m = GROUP_RE.match(line)
if m:
close_group()
g = m.group(1)
in_group, group_entries = g, []
if cur is None:
errors.append(f"line {n}: '### {g}' before any release heading")
continue
if g not in GROUPS:
errors.append(f"{cur}: unknown group '{g}' (allowed: {', '.join(GROUPS)})")
continue
i = GROUPS.index(g)
if g in [GROUPS[j] for j in seen_groups]:
errors.append(f"{cur}: duplicate group '{g}'")
elif seen_groups and i < seen_groups[-1]:
errors.append(
f"{cur}: '{g}' out of order — required order is {' > '.join(GROUPS)}"
)
seen_groups.append(i)
continue

m = LINKDEF_RE.match(line)
if m:
linkdefs.append(m.group(1))
continue

if line.startswith("- ") and in_group is not None:
entry = line[2:].strip()
group_entries.append(entry)
if entry.endswith("."):
warnings.append(f"{cur}: entry ends with a period: {entry[:60]!r}")

close_group()

ordered = sorted(releases, key=semver_key, reverse=True)
if releases != ordered:
errors.append(f"releases not sorted latest-first: {' '.join(releases)}")

for v in releases:
c = linkdefs.count(v)
if c == 0:
errors.append(f"{v}: missing link definition at the bottom of the file")
elif c > 1:
errors.append(f"{v}: {c} link definitions, expected 1")
for d in linkdefs:
if d not in releases:
errors.append(f"orphan link definition [{d}] with no matching release")

for w in warnings:
print(f"warning: {w}")
for e in errors:
print(f"error: {e}")

if errors:
print(f"\n{len(errors)} error(s) in {path}")
return 1
print(f"{path}: OK — {len(releases)} releases, format valid")
return 0


if __name__ == "__main__":
sys.exit(main())
108 changes: 108 additions & 0 deletions .claude/skills/seclai-sdk-sync/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
name: seclai-sdk-sync
description: Sync a Seclai SDK (seclai-python, seclai-javascript, seclai-go, seclai-csharp, seclai-cli, seclai-mcp) to a new OpenAPI spec, or add endpoints to one. Use when a new openapi/seclai.openapi.json has been copied in, when asked to add missing endpoints or check endpoint coverage, or when auditing an SDK against the API spec.
---

# Syncing a Seclai SDK to a new spec

Run the analysis with `sdksync.py`, bundled next to this file, rather than
hand-rolling greps. Every ad-hoc parity regex written so far has missed methods
with multi-line signatures.

```bash
S=.claude/skills/seclai-sdk-sync/sdksync.py # vendored into each SDK repo

python3 $S spec-diff HEAD # what the new spec changed
python3 $S parity . # spec paths with no request call
python3 $S api-delta 1.3.0 # public methods added since a tag
```

`parity` exits non-zero when a path is unimplemented, so it works as a CI gate.
Run it over the **whole spec**, never just the diff — `GET /me` sat unimplemented
in both the Python and JavaScript SDKs for months because each sync only looked at
its own new paths.

## The repos are not uniform

| Repo | Client | Bundles spec | Notes |
| --- | --- | --- | --- |
| seclai-python | generated + hand-written wrappers | yes | `make generate`, then black |
| seclai-javascript | types generated, methods hand-written | yes | `npm run generate` |
| seclai-go | hand-written | yes | |
| seclai-csharp | hand-rolled from the start | **no** | no codegen library was suitable; covers a subset of the API by design |
| seclai-cli | wraps `@seclai/sdk` | no | coverage question is command-to-SDK-method |
| seclai-mcp | no client source | no | |

For repos without a bundled spec, point at one:
`--spec ../seclai-python/openapi/seclai.openapi.json`.

## Workflow

1. **Confirm the spec is identical** across the repos that bundle it. They must
not diverge — a local edit is always wrong; fix the spec upstream in `seclai`.
2. **`spec-diff`** to see added/removed/changed paths and schema property changes.
3. **Regenerate**, per repo:
- python: `make generate`, then **immediately** `poetry run black .` — the
generator formats with ruff but the repo commits black, so raw output shows
~240 changed files that collapse to ~60 real ones.
- javascript: `npm run generate` (no churn; types only).
- go / csharp: nothing to regenerate.
4. **`parity`** to list what is missing. Implement every path, not just the ones
that look interesting — binary/stream endpoints with no JSON schema are the
ones that get skipped.
5. **Write the methods.** In Python they must land in **both** `Seclai` and
`AsyncSeclai`. Generating both from a single table of method definitions is
the reliable way to keep them identical; hand-writing 2×N methods drifts.
6. **Tests** — sync and async for each method, asserting verb, path, query params
and body. Python uses `httpx.MockTransport`; JavaScript uses a `makeClient`
fetch stub.
7. **README** — one section per new endpoint group.
8. **Changelog** — use the `seclai-changelog` skill.
9. **Gate**: `make lint && make test` (python) or
`npm run typecheck && npm test && npm run build` (javascript). Re-run `parity`
and confirm zero missing.

## Naming

A new method name sets precedent for all six SDKs — the first repo synced defines
it and the rest should follow. Check whether a sibling already named the same
endpoint before inventing one, and prefer the sibling's name transliterated to
local conventions (`searchDocs` / `search_docs` / `SearchDocs`).

## Typing conventions

- Return the shape the spec declares: a `$ref` becomes the aliased type, never an
untyped map. See the `seclai-changelog` skill for the full table and the
breaking-change rules.
- **JavaScript:** openapi-typescript emits any property carrying a `default` as
**required**, even when the spec omits it from `required`. Wrap the generated
request so server-defaulted fields stay optional:
`Pick<Req,"a"|"b"> & Partial<Omit<Req,"a"|"b">>` — see `AddEmailDomainInput`.
- Query params are camelCase in the method signature, snake_case on the wire.

## Repo gotchas

**seclai-python**

- `poetry run black .` will reformat the **subtree-vendored** `.claude/skills/`
files and silently drift them from canonical. `.claude` must stay excluded in
black `extend-exclude`, ruff `exclude`, and mypy `exclude`.
- `make generate` always prints
`Unable to parse schema … duplicate models with name "FileUploadResponse"`.
Pre-existing and non-fatal — `routers__api__sources__` and
`routers__api__contents__FileUploadResponse` share a title. Generation completes.
- `seclai/_generated/seclai_api_client/` is **not** regenerated and nothing
imports it. It is stale and safe to ignore; do not treat it as a source of truth.
- mypy rejects assigning the result of a `-> None` method, so tests for
204-returning endpoints must call without binding a variable.

**seclai-javascript**

- `npm run typecheck` does not cover README snippets. Paste any example into a
scratch `.ts` importing from `../src/index` and compile it before claiming it works.

## Release

The version comes from the merge commit message, read by `seclai/github-tag-action`
with `DEFAULT_BUMP: patch`. A sync that adds endpoints needs `#minor` in the PR
title, or it ships as a patch and the changelog heading will not match the tag.
Loading
Loading