@seliseblocks/cli-os 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/skills/blocks-iam-access-control/SKILL.md +49 -49
- package/dist/skills/blocks-iam-access-control/flows/feature-gating.md +38 -38
- package/dist/skills/blocks-iam-access-control/flows/manage-roles-permissions.md +110 -110
- package/dist/skills/blocks-iam-mfa/SKILL.md +124 -124
- package/dist/skills/blocks-iam-organizations/SKILL.md +43 -43
- package/dist/skills/blocks-iam-organizations/flows/admin-mutations.md +89 -89
- package/dist/skills/blocks-iam-organizations/flows/read-and-switch.md +57 -57
- package/dist/skills/blocks-iam-sso-oidc-configuration/SKILL.md +105 -105
- package/dist/skills/blocks-mail/SKILL.md +95 -95
- package/dist/skills/blocks-notification/SKILL.md +69 -69
- package/dist/skills/blocks-notifier/SKILL.md +107 -107
- package/dist/skills/blocks-onboarding/SKILL.md +5 -6
- package/dist/skills/blocks-release-deployment/SKILL.md +81 -81
- package/dist/skills/blocks-secrets/SKILL.md +81 -81
- package/dist/skills/lint.mjs +168 -168
- package/package.json +1 -1
|
@@ -1,81 +1,81 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: blocks-secrets
|
|
3
|
-
description: "Save and retrieve arbitrary named secret values (e.g. captcha provider config, third-party API keys) for a SELISE Blocks project via the blocks CLI's `secrets get`/`secrets save` commands, project-scoped with an impersonated project token. CLI-only surface, no SDK equivalent by design. Storage is generic key/value — shape depends entirely on the secret key, not fixed per type. Use for saving/rotating a secret's key-value pairs or reading one back. `get`'s response is the raw, unredacted value — treat CLI output as sensitive."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Blocks Secrets
|
|
7
|
-
|
|
8
|
-
This skill manages **generic tenant secret storage** — arbitrary named secret values scoped to a project, via the `blocks secrets *` CLI. It is not tied to any one feature: a project can store a `captcha` secret, an `smtp` secret, or anything else under whatever `secretKey` name it chooses. The shape of the stored value is a flat, caller-defined JSON object (`--key-value-pairs`) — there is no fixed schema across secrets.
|
|
9
|
-
|
|
10
|
-
**CLI-only, no SDK path, by explicit design.** There is no `@seliseblocks/client` method anywhere for reading or writing tenant secrets (the SDK's only "secret" surfaces are unrelated: MFA enrollment secrets and OIDC `clientSecret`). If a user wants to store or fetch a project secret, `blocks secrets get`/`blocks secrets save` is the only path — don't suggest an SDK call for this.
|
|
11
|
-
|
|
12
|
-
**Prerequisite:** a project is selected (`blocks use <tenantId>`). If login/project state is unknown, run the blocks-onboarding skill first.
|
|
13
|
-
|
|
14
|
-
## Command family
|
|
15
|
-
|
|
16
|
-
Both commands require an **impersonated project token** — there is no account-token path, consistent with `storage config *` and other project-scoped admin surfaces.
|
|
17
|
-
|
|
18
|
-
| Command | What it does |
|
|
19
|
-
|---|---|
|
|
20
|
-
| `blocks secrets get <secretKey>` | `<secretKey>` positional, or `--secret-key` (required if no positional). Also takes `--page-number` (default `0`) / `--page-size` (default `10`). Read-only. Response is untyped and printed as-is — **not redacted** (see Gotchas). |
|
|
21
|
-
| `blocks secrets save` | Upsert — create or update. Mutating; standard `--dry-run`/`--yes` discipline applies. |
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
blocks secrets get captcha --json
|
|
25
|
-
blocks secrets get --secret-key captcha --page-size 25 --json
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
The `secretKey` positional argument wins over `--secret-key` if both are somehow given; only one is required. The paging flags imply the response can be a paged list of items filed under that `secretKey`, not necessarily a single flat value — confirm actual shape from what the call returns rather than assuming a single-object response.
|
|
29
|
-
|
|
30
|
-
## `secrets save` — fields
|
|
31
|
-
|
|
32
|
-
`save` builds its request body from `--body`/`--file` (a raw JSON object, applied first) merged with these convenience flags (applied second, so they win if both are given):
|
|
33
|
-
|
|
34
|
-
| Flag | Body field |
|
|
35
|
-
|---|---|
|
|
36
|
-
| `--secret-key` | `secretKey` |
|
|
37
|
-
| `--item-id` | `itemId` |
|
|
38
|
-
| `--key-value-pairs` | `keyValuePairs` |
|
|
39
|
-
|
|
40
|
-
`--key-value-pairs` takes a JSON **object** string (e.g. `'{"isEnable":"true"}'`) — the CLI rejects arrays or non-objects with `--key-value-pairs must be a JSON object`. Unset convenience flags are dropped, so they never overwrite a field already present in `--body`/`--file`.
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
blocks secrets save --secret-key captcha \
|
|
44
|
-
--key-value-pairs '{"isEnable":"true","provider":"recaptcha","captchaKey":"...","captchaSecret":"..."}' \
|
|
45
|
-
--dry-run --json
|
|
46
|
-
|
|
47
|
-
blocks secrets save --secret-key captcha \
|
|
48
|
-
--key-value-pairs '{"isEnable":"true","provider":"recaptcha","captchaKey":"...","captchaSecret":"..."}' \
|
|
49
|
-
--yes --json
|
|
50
|
-
|
|
51
|
-
# Update an existing secret record
|
|
52
|
-
blocks secrets save --secret-key captcha --item-id <itemId> --key-value-pairs '{...}' --yes --json
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
`save` is create-or-update in one command: omit `--item-id` to create, pass it to update. The captcha example above is only an illustration — `--key-value-pairs` accepts whatever fields the caller's `secretKey` namespace needs.
|
|
56
|
-
|
|
57
|
-
## `--dry-run` before `--yes` — always
|
|
58
|
-
|
|
59
|
-
`save` follows the standard `blocks` mutation discipline: passing neither `--dry-run` nor `--yes` drops into an interactive "Type 'yes' to continue" prompt, which is not viable in a scripted/agent context — always pass one explicitly.
|
|
60
|
-
|
|
61
|
-
- `--dry-run` short-circuits **before** the confirmation prompt and **before any network call**: it prints the resolved request, with a redacted body, and returns.
|
|
62
|
-
- `--yes` skips the interactive prompt and sends the real request.
|
|
63
|
-
|
|
64
|
-
The dry-run preview's redaction is narrow: it walks `keyValuePairs` only, and replaces the *value* of any entry whose *key* matches a secret-shaped pattern (case-insensitive, e.g. ends in "key," or contains "secret"/"password") with `"***"`. It does **not** touch `secretKey`/`itemId` at the top level, and does **not** touch anything injected via `--body`/`--file` outside `keyValuePairs`. This redaction is preview-only — it never changes what is actually sent when `--yes` is used, and it has no effect on `secrets get`'s response or on the live response from `save` itself.
|
|
65
|
-
|
|
66
|
-
## Gotchas (secret-handling — read before running either command)
|
|
67
|
-
|
|
68
|
-
- **`get`'s response is raw and completely unredacted.** The command applies zero masking — the result is passed straight through and printed verbatim. There is no masked/redacted variant of this command. Whatever is stored under that `secretKey` comes back in full, as-is. Treat the output as sensitive: don't repeat the value back to the user beyond what they explicitly asked for, don't paste it into chat/logs/tickets, and never write it into a file that could get committed.
|
|
69
|
-
- **`save`'s live response is also unredacted.** The dry-run preview masks secret-shaped `keyValuePairs` keys, but that's a preview-only convenience. The actual request always sends plaintext values, and whatever comes back is written unredacted too — if the API echoes the saved value back, handle that response with the same care as `get`'s.
|
|
70
|
-
- **This is generic storage, not a captcha-specific feature.** `--key-value-pairs` is a flat JSON object whose fields are entirely defined by whoever picked the `secretKey` — there's no schema registry. Don't assume `isEnable`/`provider`/`captchaKey`/`captchaSecret` apply to a secret that isn't actually a captcha config.
|
|
71
|
-
- **Impersonated project token only, no account-token path.** Both commands require a selected project (`blocks use <tenantId>`) first — same pattern as `storage config *`.
|
|
72
|
-
- **No SDK equivalent exists.** Don't reach for `@seliseblocks/client` for this; the CLI is the only surface, by design.
|
|
73
|
-
- **`save` is upsert, not two verbs.** Whether a call creates or updates is decided by the presence of `--item-id`, not by a different command name.
|
|
74
|
-
|
|
75
|
-
## Example trigger prompts
|
|
76
|
-
|
|
77
|
-
- "Save our reCAPTCHA settings as a project secret." → `secrets save --secret-key captcha --key-value-pairs '{...}' --dry-run --json`, confirm, then re-run with `--yes`.
|
|
78
|
-
- "What's stored under the `captcha` secret?" → `secrets get captcha --json` — tell the user the raw stored value will be printed, and don't restate it beyond what they asked for.
|
|
79
|
-
- "Rotate the captcha secret key." → `secrets save --secret-key captcha --item-id <itemId> --key-value-pairs '{...}' --yes --json` (update path — need the existing `itemId`, typically from a prior `secrets get`).
|
|
80
|
-
- "Is there a way to list every secret in the project?" → there's no list-all; `secrets get` requires a `secretKey` and pages within it (`--page-number`/`--page-size`), it doesn't enumerate unknown keys.
|
|
81
|
-
- "Can I read this from my frontend app with the SDK?" → no — `blocks secrets *` is CLI/admin-only; don't scaffold an SDK call, and never put a secret value into frontend code or a committed `.env` file.
|
|
1
|
+
---
|
|
2
|
+
name: blocks-secrets
|
|
3
|
+
description: "Save and retrieve arbitrary named secret values (e.g. captcha provider config, third-party API keys) for a SELISE Blocks project via the blocks CLI's `secrets get`/`secrets save` commands, project-scoped with an impersonated project token. CLI-only surface, no SDK equivalent by design. Storage is generic key/value — shape depends entirely on the secret key, not fixed per type. Use for saving/rotating a secret's key-value pairs or reading one back. `get`'s response is the raw, unredacted value — treat CLI output as sensitive."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Blocks Secrets
|
|
7
|
+
|
|
8
|
+
This skill manages **generic tenant secret storage** — arbitrary named secret values scoped to a project, via the `blocks secrets *` CLI. It is not tied to any one feature: a project can store a `captcha` secret, an `smtp` secret, or anything else under whatever `secretKey` name it chooses. The shape of the stored value is a flat, caller-defined JSON object (`--key-value-pairs`) — there is no fixed schema across secrets.
|
|
9
|
+
|
|
10
|
+
**CLI-only, no SDK path, by explicit design.** There is no `@seliseblocks/client` method anywhere for reading or writing tenant secrets (the SDK's only "secret" surfaces are unrelated: MFA enrollment secrets and OIDC `clientSecret`). If a user wants to store or fetch a project secret, `blocks secrets get`/`blocks secrets save` is the only path — don't suggest an SDK call for this.
|
|
11
|
+
|
|
12
|
+
**Prerequisite:** a project is selected (`blocks use <tenantId>`). If login/project state is unknown, run the blocks-onboarding skill first.
|
|
13
|
+
|
|
14
|
+
## Command family
|
|
15
|
+
|
|
16
|
+
Both commands require an **impersonated project token** — there is no account-token path, consistent with `storage config *` and other project-scoped admin surfaces.
|
|
17
|
+
|
|
18
|
+
| Command | What it does |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `blocks secrets get <secretKey>` | `<secretKey>` positional, or `--secret-key` (required if no positional). Also takes `--page-number` (default `0`) / `--page-size` (default `10`). Read-only. Response is untyped and printed as-is — **not redacted** (see Gotchas). |
|
|
21
|
+
| `blocks secrets save` | Upsert — create or update. Mutating; standard `--dry-run`/`--yes` discipline applies. |
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
blocks secrets get captcha --json
|
|
25
|
+
blocks secrets get --secret-key captcha --page-size 25 --json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The `secretKey` positional argument wins over `--secret-key` if both are somehow given; only one is required. The paging flags imply the response can be a paged list of items filed under that `secretKey`, not necessarily a single flat value — confirm actual shape from what the call returns rather than assuming a single-object response.
|
|
29
|
+
|
|
30
|
+
## `secrets save` — fields
|
|
31
|
+
|
|
32
|
+
`save` builds its request body from `--body`/`--file` (a raw JSON object, applied first) merged with these convenience flags (applied second, so they win if both are given):
|
|
33
|
+
|
|
34
|
+
| Flag | Body field |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `--secret-key` | `secretKey` |
|
|
37
|
+
| `--item-id` | `itemId` |
|
|
38
|
+
| `--key-value-pairs` | `keyValuePairs` |
|
|
39
|
+
|
|
40
|
+
`--key-value-pairs` takes a JSON **object** string (e.g. `'{"isEnable":"true"}'`) — the CLI rejects arrays or non-objects with `--key-value-pairs must be a JSON object`. Unset convenience flags are dropped, so they never overwrite a field already present in `--body`/`--file`.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
blocks secrets save --secret-key captcha \
|
|
44
|
+
--key-value-pairs '{"isEnable":"true","provider":"recaptcha","captchaKey":"...","captchaSecret":"..."}' \
|
|
45
|
+
--dry-run --json
|
|
46
|
+
|
|
47
|
+
blocks secrets save --secret-key captcha \
|
|
48
|
+
--key-value-pairs '{"isEnable":"true","provider":"recaptcha","captchaKey":"...","captchaSecret":"..."}' \
|
|
49
|
+
--yes --json
|
|
50
|
+
|
|
51
|
+
# Update an existing secret record
|
|
52
|
+
blocks secrets save --secret-key captcha --item-id <itemId> --key-value-pairs '{...}' --yes --json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`save` is create-or-update in one command: omit `--item-id` to create, pass it to update. The captcha example above is only an illustration — `--key-value-pairs` accepts whatever fields the caller's `secretKey` namespace needs.
|
|
56
|
+
|
|
57
|
+
## `--dry-run` before `--yes` — always
|
|
58
|
+
|
|
59
|
+
`save` follows the standard `blocks` mutation discipline: passing neither `--dry-run` nor `--yes` drops into an interactive "Type 'yes' to continue" prompt, which is not viable in a scripted/agent context — always pass one explicitly.
|
|
60
|
+
|
|
61
|
+
- `--dry-run` short-circuits **before** the confirmation prompt and **before any network call**: it prints the resolved request, with a redacted body, and returns.
|
|
62
|
+
- `--yes` skips the interactive prompt and sends the real request.
|
|
63
|
+
|
|
64
|
+
The dry-run preview's redaction is narrow: it walks `keyValuePairs` only, and replaces the *value* of any entry whose *key* matches a secret-shaped pattern (case-insensitive, e.g. ends in "key," or contains "secret"/"password") with `"***"`. It does **not** touch `secretKey`/`itemId` at the top level, and does **not** touch anything injected via `--body`/`--file` outside `keyValuePairs`. This redaction is preview-only — it never changes what is actually sent when `--yes` is used, and it has no effect on `secrets get`'s response or on the live response from `save` itself.
|
|
65
|
+
|
|
66
|
+
## Gotchas (secret-handling — read before running either command)
|
|
67
|
+
|
|
68
|
+
- **`get`'s response is raw and completely unredacted.** The command applies zero masking — the result is passed straight through and printed verbatim. There is no masked/redacted variant of this command. Whatever is stored under that `secretKey` comes back in full, as-is. Treat the output as sensitive: don't repeat the value back to the user beyond what they explicitly asked for, don't paste it into chat/logs/tickets, and never write it into a file that could get committed.
|
|
69
|
+
- **`save`'s live response is also unredacted.** The dry-run preview masks secret-shaped `keyValuePairs` keys, but that's a preview-only convenience. The actual request always sends plaintext values, and whatever comes back is written unredacted too — if the API echoes the saved value back, handle that response with the same care as `get`'s.
|
|
70
|
+
- **This is generic storage, not a captcha-specific feature.** `--key-value-pairs` is a flat JSON object whose fields are entirely defined by whoever picked the `secretKey` — there's no schema registry. Don't assume `isEnable`/`provider`/`captchaKey`/`captchaSecret` apply to a secret that isn't actually a captcha config.
|
|
71
|
+
- **Impersonated project token only, no account-token path.** Both commands require a selected project (`blocks use <tenantId>`) first — same pattern as `storage config *`.
|
|
72
|
+
- **No SDK equivalent exists.** Don't reach for `@seliseblocks/client` for this; the CLI is the only surface, by design.
|
|
73
|
+
- **`save` is upsert, not two verbs.** Whether a call creates or updates is decided by the presence of `--item-id`, not by a different command name.
|
|
74
|
+
|
|
75
|
+
## Example trigger prompts
|
|
76
|
+
|
|
77
|
+
- "Save our reCAPTCHA settings as a project secret." → `secrets save --secret-key captcha --key-value-pairs '{...}' --dry-run --json`, confirm, then re-run with `--yes`.
|
|
78
|
+
- "What's stored under the `captcha` secret?" → `secrets get captcha --json` — tell the user the raw stored value will be printed, and don't restate it beyond what they asked for.
|
|
79
|
+
- "Rotate the captcha secret key." → `secrets save --secret-key captcha --item-id <itemId> --key-value-pairs '{...}' --yes --json` (update path — need the existing `itemId`, typically from a prior `secrets get`).
|
|
80
|
+
- "Is there a way to list every secret in the project?" → there's no list-all; `secrets get` requires a `secretKey` and pages within it (`--page-number`/`--page-size`), it doesn't enumerate unknown keys.
|
|
81
|
+
- "Can I read this from my frontend app with the SDK?" → no — `blocks secrets *` is CLI/admin-only; don't scaffold an SDK call, and never put a secret value into frontend code or a committed `.env` file.
|
package/dist/skills/lint.mjs
CHANGED
|
@@ -1,168 +1,168 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Consistency lint for blocks-skills/. Run: node blocks-skills/lint.mjs
|
|
3
|
-
//
|
|
4
|
-
// A skill is consumed by an AI that has ONLY the globally-installed `blocks`
|
|
5
|
-
// CLI and a project-local `@seliseblocks/client` -- never this monorepo, and
|
|
6
|
-
// `blocks skill add` pulls exactly one skill directory at a time. Checks:
|
|
7
|
-
// 1. Every skill directory has a SKILL.md with frontmatter: `name` matches the
|
|
8
|
-
// directory name, `name` <= 64 chars, `description` present, non-empty,
|
|
9
|
-
// on a single physical line (blocks-cli's own frontmatter parser --
|
|
10
|
-
// src/lib/skills.ts -- is a hand-rolled line-by-line parser with no YAML
|
|
11
|
-
// dependency; a description that wraps onto a second line silently breaks
|
|
12
|
-
// `blocks skill list`/`show`), and <= 1024 chars (hard fail) / <= 700 chars
|
|
13
|
-
// (warn -- this pack's house style target is ~400-600).
|
|
14
|
-
// 2. Relative markdown links (in SKILL.md and any flows/*.md) resolve to a
|
|
15
|
-
// real file.
|
|
16
|
-
// 3. No links leave the containing skill's own directory at all -- not into
|
|
17
|
-
// another skill's SKILL.md, not into its flows/, not to a monorepo-only
|
|
18
|
-
// file outside blocks-skills/. A skill may mention another skill BY NAME
|
|
19
|
-
// in plain text, never as a link, since the target isn't guaranteed to be
|
|
20
|
-
// present for a consumer who only pulled this one skill. Links within the
|
|
21
|
-
// same skill's own directory (SKILL.md <-> its own flows/*.md) are fine.
|
|
22
|
-
// 4. No raw API endpoint paths (e.g. `/iam/v4/...`, `/os/v4/...`) -- skills
|
|
23
|
-
// describe CLI commands and SDK methods, never the wire protocol behind
|
|
24
|
-
// them; citing a path is exactly the kind of detail that could tempt a
|
|
25
|
-
// raw fetch/curl bypass every skill already forbids.
|
|
26
|
-
// Exit 0 = clean, 1 = problems found (all listed, not just the first).
|
|
27
|
-
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
28
|
-
import { dirname, join, relative } from "node:path";
|
|
29
|
-
import { fileURLToPath } from "node:url";
|
|
30
|
-
|
|
31
|
-
const skillsDir = dirname(fileURLToPath(import.meta.url));
|
|
32
|
-
const errors = [];
|
|
33
|
-
const warnings = [];
|
|
34
|
-
|
|
35
|
-
const DESCRIPTION_HARD_LIMIT = 1024;
|
|
36
|
-
const DESCRIPTION_WARN_LIMIT = 700;
|
|
37
|
-
const NAME_LIMIT = 64;
|
|
38
|
-
const ENDPOINT_PATTERN = /\/(iam|data|os|logic|release|localization)\/v4\/[A-Za-z0-9/{}._-]*/g;
|
|
39
|
-
|
|
40
|
-
const skillDirs = readdirSync(skillsDir, { withFileTypes: true })
|
|
41
|
-
.filter((entry) => entry.isDirectory())
|
|
42
|
-
.map((entry) => entry.name)
|
|
43
|
-
.sort();
|
|
44
|
-
|
|
45
|
-
for (const skillName of skillDirs) {
|
|
46
|
-
const skillPath = join(skillsDir, skillName);
|
|
47
|
-
const skillMdPath = join(skillPath, "SKILL.md");
|
|
48
|
-
|
|
49
|
-
if (!existsSync(skillMdPath)) {
|
|
50
|
-
errors.push(`${skillName}/: no SKILL.md`);
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
checkFrontmatter(skillName, skillMdPath);
|
|
55
|
-
checkLinksInFile(skillName, skillMdPath);
|
|
56
|
-
checkEndpointsInFile(skillMdPath);
|
|
57
|
-
|
|
58
|
-
const flowsDir = join(skillPath, "flows");
|
|
59
|
-
if (existsSync(flowsDir) && statSync(flowsDir).isDirectory()) {
|
|
60
|
-
for (const entry of readdirSync(flowsDir, { withFileTypes: true })) {
|
|
61
|
-
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
62
|
-
const flowPath = join(flowsDir, entry.name);
|
|
63
|
-
checkLinksInFile(skillName, flowPath);
|
|
64
|
-
checkEndpointsInFile(flowPath);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function checkFrontmatter(skillName, skillMdPath) {
|
|
71
|
-
const raw = readFileSync(skillMdPath, "utf8");
|
|
72
|
-
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
73
|
-
const rel = relative(skillsDir, skillMdPath);
|
|
74
|
-
|
|
75
|
-
if (!match) {
|
|
76
|
-
errors.push(`${rel}: missing frontmatter (expected a leading --- ... --- block)`);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const lines = match[1].split(/\r?\n/);
|
|
81
|
-
const nameLine = lines.find((line) => line.startsWith("name:"));
|
|
82
|
-
const descLine = lines.find((line) => line.startsWith("description:"));
|
|
83
|
-
|
|
84
|
-
if (!nameLine) {
|
|
85
|
-
errors.push(`${rel}: frontmatter has no 'name' field`);
|
|
86
|
-
} else {
|
|
87
|
-
const name = nameLine.slice("name:".length).trim();
|
|
88
|
-
if (name !== skillName) {
|
|
89
|
-
errors.push(`${rel}: name '${name}' does not match directory name '${skillName}'`);
|
|
90
|
-
}
|
|
91
|
-
if (name.length > NAME_LIMIT) {
|
|
92
|
-
errors.push(`${rel}: name is ${name.length} chars, over the ${NAME_LIMIT}-char limit`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (!descLine) {
|
|
97
|
-
errors.push(`${rel}: frontmatter has no 'description' field`);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const singleLineMatch = descLine.match(/^description:\s*"(.*)"\s*$/);
|
|
102
|
-
if (!singleLineMatch) {
|
|
103
|
-
errors.push(
|
|
104
|
-
`${rel}: 'description' must be a double-quoted string on a single physical line ` +
|
|
105
|
-
`(blocks-cli's frontmatter parser reads it line-by-line -- a wrapped description silently truncates)`
|
|
106
|
-
);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const description = singleLineMatch[1];
|
|
111
|
-
if (description.length === 0) {
|
|
112
|
-
errors.push(`${rel}: 'description' is empty`);
|
|
113
|
-
} else if (description.length > DESCRIPTION_HARD_LIMIT) {
|
|
114
|
-
errors.push(`${rel}: description is ${description.length} chars, over the ${DESCRIPTION_HARD_LIMIT}-char hard limit`);
|
|
115
|
-
} else if (description.length > DESCRIPTION_WARN_LIMIT) {
|
|
116
|
-
warnings.push(`${rel}: description is ${description.length} chars, over the ${DESCRIPTION_WARN_LIMIT}-char house-style target (aim for ~400-600)`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function checkLinksInFile(skillName, filePath) {
|
|
121
|
-
const raw = readFileSync(filePath, "utf8");
|
|
122
|
-
const rel = relative(skillsDir, filePath);
|
|
123
|
-
const linkPattern = /\[[^\]]*\]\(([^)]+)\)/g;
|
|
124
|
-
const fileDir = dirname(filePath);
|
|
125
|
-
|
|
126
|
-
for (const match of raw.matchAll(linkPattern)) {
|
|
127
|
-
const target = match[1].trim();
|
|
128
|
-
if (/^[a-z]+:\/\//i.test(target) || target.startsWith("#")) continue; // external URL or in-page anchor
|
|
129
|
-
|
|
130
|
-
const [pathPart] = target.split("#");
|
|
131
|
-
if (!pathPart) continue;
|
|
132
|
-
|
|
133
|
-
const resolved = join(fileDir, pathPart);
|
|
134
|
-
if (!existsSync(resolved)) {
|
|
135
|
-
errors.push(`${rel}: broken link to '${pathPart}' (resolved: ${relative(skillsDir, resolved)})`);
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const resolvedRelToSkills = relative(skillsDir, resolved).split(/[\\/]/);
|
|
140
|
-
const targetSkill = resolvedRelToSkills[0];
|
|
141
|
-
if (targetSkill !== skillName) {
|
|
142
|
-
errors.push(
|
|
143
|
-
`${rel}: link leaves this skill's own directory ('${pathPart}') -- ` +
|
|
144
|
-
`mention other skills by name in plain text instead, never a link, since ` +
|
|
145
|
-
`'blocks skill add' only copies one skill directory at a time and the target isn't guaranteed to be present`
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function checkEndpointsInFile(filePath) {
|
|
152
|
-
const raw = readFileSync(filePath, "utf8");
|
|
153
|
-
const rel = relative(skillsDir, filePath);
|
|
154
|
-
|
|
155
|
-
for (const match of raw.matchAll(ENDPOINT_PATTERN)) {
|
|
156
|
-
errors.push(`${rel}: raw API endpoint path '${match[0]}' -- describe the CLI command/SDK method instead, never the wire path`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
for (const warning of warnings) console.warn(`warning: ${warning}`);
|
|
161
|
-
if (errors.length === 0) {
|
|
162
|
-
console.log(`ok: ${skillDirs.length} skills, no problems found${warnings.length ? ` (${warnings.length} warning(s) above)` : ""}`);
|
|
163
|
-
process.exit(0);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
console.error(`${errors.length} problem(s) found:`);
|
|
167
|
-
for (const error of errors) console.error(` - ${error}`);
|
|
168
|
-
process.exit(1);
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Consistency lint for blocks-skills/. Run: node blocks-skills/lint.mjs
|
|
3
|
+
//
|
|
4
|
+
// A skill is consumed by an AI that has ONLY the globally-installed `blocks`
|
|
5
|
+
// CLI and a project-local `@seliseblocks/client` -- never this monorepo, and
|
|
6
|
+
// `blocks skill add` pulls exactly one skill directory at a time. Checks:
|
|
7
|
+
// 1. Every skill directory has a SKILL.md with frontmatter: `name` matches the
|
|
8
|
+
// directory name, `name` <= 64 chars, `description` present, non-empty,
|
|
9
|
+
// on a single physical line (blocks-cli's own frontmatter parser --
|
|
10
|
+
// src/lib/skills.ts -- is a hand-rolled line-by-line parser with no YAML
|
|
11
|
+
// dependency; a description that wraps onto a second line silently breaks
|
|
12
|
+
// `blocks skill list`/`show`), and <= 1024 chars (hard fail) / <= 700 chars
|
|
13
|
+
// (warn -- this pack's house style target is ~400-600).
|
|
14
|
+
// 2. Relative markdown links (in SKILL.md and any flows/*.md) resolve to a
|
|
15
|
+
// real file.
|
|
16
|
+
// 3. No links leave the containing skill's own directory at all -- not into
|
|
17
|
+
// another skill's SKILL.md, not into its flows/, not to a monorepo-only
|
|
18
|
+
// file outside blocks-skills/. A skill may mention another skill BY NAME
|
|
19
|
+
// in plain text, never as a link, since the target isn't guaranteed to be
|
|
20
|
+
// present for a consumer who only pulled this one skill. Links within the
|
|
21
|
+
// same skill's own directory (SKILL.md <-> its own flows/*.md) are fine.
|
|
22
|
+
// 4. No raw API endpoint paths (e.g. `/iam/v4/...`, `/os/v4/...`) -- skills
|
|
23
|
+
// describe CLI commands and SDK methods, never the wire protocol behind
|
|
24
|
+
// them; citing a path is exactly the kind of detail that could tempt a
|
|
25
|
+
// raw fetch/curl bypass every skill already forbids.
|
|
26
|
+
// Exit 0 = clean, 1 = problems found (all listed, not just the first).
|
|
27
|
+
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
28
|
+
import { dirname, join, relative } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
const skillsDir = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const errors = [];
|
|
33
|
+
const warnings = [];
|
|
34
|
+
|
|
35
|
+
const DESCRIPTION_HARD_LIMIT = 1024;
|
|
36
|
+
const DESCRIPTION_WARN_LIMIT = 700;
|
|
37
|
+
const NAME_LIMIT = 64;
|
|
38
|
+
const ENDPOINT_PATTERN = /\/(iam|data|os|logic|release|localization)\/v4\/[A-Za-z0-9/{}._-]*/g;
|
|
39
|
+
|
|
40
|
+
const skillDirs = readdirSync(skillsDir, { withFileTypes: true })
|
|
41
|
+
.filter((entry) => entry.isDirectory())
|
|
42
|
+
.map((entry) => entry.name)
|
|
43
|
+
.sort();
|
|
44
|
+
|
|
45
|
+
for (const skillName of skillDirs) {
|
|
46
|
+
const skillPath = join(skillsDir, skillName);
|
|
47
|
+
const skillMdPath = join(skillPath, "SKILL.md");
|
|
48
|
+
|
|
49
|
+
if (!existsSync(skillMdPath)) {
|
|
50
|
+
errors.push(`${skillName}/: no SKILL.md`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
checkFrontmatter(skillName, skillMdPath);
|
|
55
|
+
checkLinksInFile(skillName, skillMdPath);
|
|
56
|
+
checkEndpointsInFile(skillMdPath);
|
|
57
|
+
|
|
58
|
+
const flowsDir = join(skillPath, "flows");
|
|
59
|
+
if (existsSync(flowsDir) && statSync(flowsDir).isDirectory()) {
|
|
60
|
+
for (const entry of readdirSync(flowsDir, { withFileTypes: true })) {
|
|
61
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
62
|
+
const flowPath = join(flowsDir, entry.name);
|
|
63
|
+
checkLinksInFile(skillName, flowPath);
|
|
64
|
+
checkEndpointsInFile(flowPath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function checkFrontmatter(skillName, skillMdPath) {
|
|
71
|
+
const raw = readFileSync(skillMdPath, "utf8");
|
|
72
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
73
|
+
const rel = relative(skillsDir, skillMdPath);
|
|
74
|
+
|
|
75
|
+
if (!match) {
|
|
76
|
+
errors.push(`${rel}: missing frontmatter (expected a leading --- ... --- block)`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lines = match[1].split(/\r?\n/);
|
|
81
|
+
const nameLine = lines.find((line) => line.startsWith("name:"));
|
|
82
|
+
const descLine = lines.find((line) => line.startsWith("description:"));
|
|
83
|
+
|
|
84
|
+
if (!nameLine) {
|
|
85
|
+
errors.push(`${rel}: frontmatter has no 'name' field`);
|
|
86
|
+
} else {
|
|
87
|
+
const name = nameLine.slice("name:".length).trim();
|
|
88
|
+
if (name !== skillName) {
|
|
89
|
+
errors.push(`${rel}: name '${name}' does not match directory name '${skillName}'`);
|
|
90
|
+
}
|
|
91
|
+
if (name.length > NAME_LIMIT) {
|
|
92
|
+
errors.push(`${rel}: name is ${name.length} chars, over the ${NAME_LIMIT}-char limit`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!descLine) {
|
|
97
|
+
errors.push(`${rel}: frontmatter has no 'description' field`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const singleLineMatch = descLine.match(/^description:\s*"(.*)"\s*$/);
|
|
102
|
+
if (!singleLineMatch) {
|
|
103
|
+
errors.push(
|
|
104
|
+
`${rel}: 'description' must be a double-quoted string on a single physical line ` +
|
|
105
|
+
`(blocks-cli's frontmatter parser reads it line-by-line -- a wrapped description silently truncates)`
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const description = singleLineMatch[1];
|
|
111
|
+
if (description.length === 0) {
|
|
112
|
+
errors.push(`${rel}: 'description' is empty`);
|
|
113
|
+
} else if (description.length > DESCRIPTION_HARD_LIMIT) {
|
|
114
|
+
errors.push(`${rel}: description is ${description.length} chars, over the ${DESCRIPTION_HARD_LIMIT}-char hard limit`);
|
|
115
|
+
} else if (description.length > DESCRIPTION_WARN_LIMIT) {
|
|
116
|
+
warnings.push(`${rel}: description is ${description.length} chars, over the ${DESCRIPTION_WARN_LIMIT}-char house-style target (aim for ~400-600)`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function checkLinksInFile(skillName, filePath) {
|
|
121
|
+
const raw = readFileSync(filePath, "utf8");
|
|
122
|
+
const rel = relative(skillsDir, filePath);
|
|
123
|
+
const linkPattern = /\[[^\]]*\]\(([^)]+)\)/g;
|
|
124
|
+
const fileDir = dirname(filePath);
|
|
125
|
+
|
|
126
|
+
for (const match of raw.matchAll(linkPattern)) {
|
|
127
|
+
const target = match[1].trim();
|
|
128
|
+
if (/^[a-z]+:\/\//i.test(target) || target.startsWith("#")) continue; // external URL or in-page anchor
|
|
129
|
+
|
|
130
|
+
const [pathPart] = target.split("#");
|
|
131
|
+
if (!pathPart) continue;
|
|
132
|
+
|
|
133
|
+
const resolved = join(fileDir, pathPart);
|
|
134
|
+
if (!existsSync(resolved)) {
|
|
135
|
+
errors.push(`${rel}: broken link to '${pathPart}' (resolved: ${relative(skillsDir, resolved)})`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const resolvedRelToSkills = relative(skillsDir, resolved).split(/[\\/]/);
|
|
140
|
+
const targetSkill = resolvedRelToSkills[0];
|
|
141
|
+
if (targetSkill !== skillName) {
|
|
142
|
+
errors.push(
|
|
143
|
+
`${rel}: link leaves this skill's own directory ('${pathPart}') -- ` +
|
|
144
|
+
`mention other skills by name in plain text instead, never a link, since ` +
|
|
145
|
+
`'blocks skill add' only copies one skill directory at a time and the target isn't guaranteed to be present`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function checkEndpointsInFile(filePath) {
|
|
152
|
+
const raw = readFileSync(filePath, "utf8");
|
|
153
|
+
const rel = relative(skillsDir, filePath);
|
|
154
|
+
|
|
155
|
+
for (const match of raw.matchAll(ENDPOINT_PATTERN)) {
|
|
156
|
+
errors.push(`${rel}: raw API endpoint path '${match[0]}' -- describe the CLI command/SDK method instead, never the wire path`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const warning of warnings) console.warn(`warning: ${warning}`);
|
|
161
|
+
if (errors.length === 0) {
|
|
162
|
+
console.log(`ok: ${skillDirs.length} skills, no problems found${warnings.length ? ` (${warnings.length} warning(s) above)` : ""}`);
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
console.error(`${errors.length} problem(s) found:`);
|
|
167
|
+
for (const error of errors) console.error(` - ${error}`);
|
|
168
|
+
process.exit(1);
|