@taskless/cli 0.10.2 → 0.11.1

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 CHANGED
@@ -1,235 +1,105 @@
1
1
  # @taskless/cli
2
2
 
3
- > A Work in Progress
3
+ [![npm](https://img.shields.io/npm/v/@taskless/cli)](https://www.npmjs.com/package/@taskless/cli)
4
+ [![build](https://img.shields.io/github/actions/workflow/status/taskless/cli/validate.yml?branch=main)](https://github.com/taskless/cli/actions/workflows/validate.yml?query=branch%3Amain)
5
+ [![nightly](https://img.shields.io/badge/nightly-npm-blue)](https://www.npmjs.com/package/@taskless/cli-nightly)
6
+ [![vale](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/taskless/cli/main/.shields/vale.json)](https://github.com/errata-ai/vale/releases)
7
+ [![sg](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/taskless/cli/main/.shields/sg.json)](https://github.com/ast-grep/ast-grep/releases)
4
8
 
5
- CLI companion for [Taskless](https://taskless.io). Designed to be invoked by skills via `npx` or `pnpm dlx`. Useful for agents, and works for humans too.
9
+ CLI companion for [Taskless](https://taskless.io). Designed to work with agent skills to add constraints that improve coding agent output.
6
10
 
7
- ## Usage
11
+ ## Install
8
12
 
9
13
  ```bash
10
- # npm
11
- npx @taskless/cli@latest info
12
-
13
- # pnpm
14
- pnpm dlx @taskless/cli@latest info
15
- ```
16
-
17
- ## Commands
18
-
19
- ### `taskless info`
20
-
21
- Outputs CLI version, tool status, and login info as JSON to stdout:
22
-
23
- ```json
24
- {
25
- "version": "0.0.3",
26
- "tools": [],
27
- "loggedIn": true,
28
- "auth": { "user": "jake", "email": "jake@example.com", "orgs": ["my-org"] }
29
- }
30
- ```
31
-
32
- ### `taskless init`
33
-
34
- Launches an interactive wizard that detects supported tool directories in the
35
- current project (`.claude/`, `.opencode/`, `.cursor/`, `.agents/`), asks which
36
- tools to enable Taskless for, and walks through the auth tradeoff before
37
- writing anything. Running `taskless` with no subcommand in a TTY also launches
38
- this wizard. Without a TTY, bare `taskless` prints a short context preamble
39
- followed by the topic index from `taskless help`.
40
-
41
- In v0.7+, there is exactly one skill (`taskless`) and one command (`tskl`) —
42
- no opt-in selection needed.
43
-
44
- The skill and command content is written **once** to a canonical store in
45
- `.taskless/skills/` and `.taskless/commands/`. Each enabled tool directory
46
- receives only a thin reference stub — an ordinary file with a delegating body,
47
- never a symlink — so there is a single source of truth and no drift between
48
- copies. Stale layouts from earlier versions (full per-tool copies, symlinks)
49
- are converged into stubs automatically on the next `init`/`update`.
50
-
51
- For CI and scripted installs, pass `--no-interactive` to skip all prompts:
52
-
53
- ```bash
54
- taskless init # interactive wizard (default in a TTY)
55
- taskless init --no-interactive # scripted install, no prompts
14
+ npx @taskless/cli
56
15
  ```
57
16
 
58
- The wizard records what it installed in `.taskless/taskless.json` so later
59
- runs can compute a diff and surgically remove files that are no longer
60
- selected. Upgrading from v0.6 automatically removes the obsolete per-task
61
- skills and commands during this diff. Cancelling the wizard at any step
62
- (Ctrl-C) aborts cleanly with no filesystem changes.
63
-
64
- ### `taskless onboard`
65
-
66
- Post-install discovery flow that helps a fresh user go from zero rules to a
67
- useful starter set. Run it after `taskless init`. The CLI prints an
68
- agent-facing recipe that walks the host AI tool through scanning the
69
- codebase, agent-memory files (CLAUDE.md / AGENTS.md / .cursorrules),
70
- recent PR review comments (when `gh` is available), and issue tracker
71
- tickets (when a relevant MCP is wired in) for high-signal rule
72
- candidates, then surfaces them as a bullet list the user can choose to
73
- materialize via `taskless rule create`.
17
+ Run with no arguments in a terminal to launch the installer, which detects the
18
+ agent tools in your project (Claude Code, Cursor, OpenCode) and installs into
19
+ each of them. For scripted installs, skip the prompts:
74
20
 
75
21
  ```bash
76
- taskless onboard # print the recipe (refused if already complete)
77
- taskless onboard --force # re-run even when previously marked complete
78
- taskless onboard --mark-complete # record completion in .taskless/taskless.json
79
- # (invoked by the agent after explicit user
80
- # confirmation; never automatically)
22
+ npx @taskless/cli init --no-interactive
81
23
  ```
82
24
 
83
- Onboarding state lives in `.taskless/taskless.json` as
84
- `install.onboarded` a 3-state optional field (absent / `false` / `true`).
85
- Only the agent writes it, and only with the user's explicit confirmation.
86
- `taskless init` does not set it. Pass `--force` to re-run regardless of the
87
- current value.
88
-
89
- After a successful `taskless init`, the CLI prints a one-line trailer
90
- pointing the user at this command. The trailer wording adapts to the
91
- install plan: when the install included slash commands (Claude Code or
92
- Cursor), it mentions `/tskl onboard` along with the Taskless skill and the
93
- bare CLI; when the install only wrote skills (OpenCode, Codex, the
94
- `.agents/` fallback), it mentions the skill and the bare CLI only.
25
+ New to Taskless? Run `npx @taskless/cli onboard` after installing. It walks your
26
+ agent through your codebase and suggests a starter set of rules.
95
27
 
96
- ### `taskless check`
28
+ ## How to Use via Agents
97
29
 
98
- Run ast-grep rules from `.taskless/rules/` against the codebase. Exits with code 1 if any error-severity matches are found.
30
+ Installing adds one skill (`taskless`) and one slash command (`/tskl`). The skill
31
+ body is a small router: your agent fetches the canonical recipe for whatever you
32
+ asked for, then follows it.
99
33
 
100
- ```bash
101
- taskless check # human-readable output, scans whole project
102
- taskless check --json # JSON output
103
34
  ```
104
-
105
- Accepts optional positional path arguments to scan only specific files or
106
- directories — useful for CI workflows that only want to check changed files.
107
- Paths that don't exist on disk (e.g. files deleted in a diff) are silently
108
- filtered, so raw git-diff output can be piped in directly:
109
-
110
- ```bash
111
- taskless check src/foo.ts src/bar.ts
112
- taskless check $(git diff --name-only main...HEAD) # PR-only scan
113
- taskless check $(git diff --cached --name-only) # pre-commit scan
35
+ /tskl create a rule that bans console.log
36
+ /tskl add taskless to CI
114
37
  ```
115
38
 
116
- If every supplied path is missing, the command exits 0 with empty results.
39
+ Plain language works too: "write a taskless rule for X", "run taskless check",
40
+ "taskless login" all engage the skill. You rarely need to run the CLI yourself.
117
41
 
118
- ### `taskless auth login` / `taskless auth logout`
42
+ To see what the agent sees, run `npx @taskless/cli agent` for the topic index, or
43
+ `npx @taskless/cli agent <topic>` for a full recipe.
119
44
 
120
- Authenticate with taskless.io using the device flow. Tokens are stored in `~/.config/taskless/auth.json`.
45
+ ## Taskless Check (CI and Constraints)
121
46
 
122
- ### `taskless rule create`
123
-
124
- Generate ast-grep rules via the taskless.io API. Reads a JSON request from stdin, submits it, polls for results, and writes rule and test files to `.taskless/rules/` and `.taskless/rule-tests/`.
47
+ `taskless check` runs your rules against the codebase. It exits `0` when nothing
48
+ error-severity matched and `1` when something did, so it drops straight into a
49
+ pipeline:
125
50
 
126
51
  ```bash
127
- echo '{"prompt": "detect console.log usage"}' | taskless rule create
128
- echo '{"prompt": "find innerHTML assignments", "language": "typescript"}' | taskless rule create --json
52
+ npx @taskless/cli check # scan everything
53
+ npx @taskless/cli check $(git diff --name-only main...HEAD) # PR-only scan
54
+ npx @taskless/cli check --json # machine-readable
129
55
  ```
130
56
 
131
- Requires authentication and a `.taskless/taskless.json` with `orgId` and `repositoryUrl`.
57
+ Paths that no longer exist are dropped silently, so raw `git diff` output can be
58
+ piped in without pre-filtering. Static rules need no login and make no network
59
+ calls, so CI needs no secrets. Runtime rules (which execute code) only run once
60
+ the server has verified their signature; otherwise they are reported as skipped
61
+ and never change the exit code.
132
62
 
133
- ### `taskless rule delete <id>`
63
+ Ask your agent to `/tskl add taskless to CI` and it will wire this into the CI
64
+ system you already use rather than replacing it.
134
65
 
135
- Remove a rule file and its associated test files from disk. No authentication required.
66
+ ## Why Teams Choose Taskless
136
67
 
137
- ```bash
138
- taskless rule delete no-console-log
139
- ```
68
+ - **Constraints, not suggestions.** Rules are real files in your repo, enforced
69
+ by ast-grep, Vale, and runtime checks: the same result every run, for every
70
+ agent and every human.
71
+ - **The same rules in the editor and in CI.** One command, one exit code.
72
+ - **Works with the agent you already have.** One skill installs into Claude Code,
73
+ Cursor, and OpenCode, plus the `/tskl` command wherever the tool supports slash
74
+ commands, with a plain `.agents/` fallback when none is detected.
75
+ - **Nothing to run locally.** No daemon, no install step in CI, no auth for the
76
+ checks that matter most.
140
77
 
141
- ### `taskless --help`
78
+ ## Docs
142
79
 
143
- Lists available subcommands.
80
+ - [docs.taskless.io](https://docs.taskless.io): guides and reference
81
+ - [taskless.io](https://taskless.io): the product
82
+ - [github.com/taskless/cli](https://github.com/taskless/cli): source and issues
144
83
 
145
- ### `taskless help [topic]`
84
+ <details>
85
+ <summary><strong>Other</strong></summary>
146
86
 
147
- Returns agent-facing recipes. With no args, prints the topic index. With a
148
- topic (e.g. `taskless help rule create`), prints the full step-by-step recipe
149
- for that operation, including an embedded JSON Schema for any `--from` input
150
- and a table of stable error codes. Append `--anonymous` to fetch the
151
- local-only variant where one exists (currently `rule create`/`rule improve`).
87
+ ### Telemetry
152
88
 
153
- Recipes are how the consolidated `taskless` skill stays small while still
154
- covering every operation the skill body is a router that fetches the
155
- relevant recipe on demand.
156
-
157
- ### `--anonymous` flag
158
-
159
- Recognized on every command. Behavior matrix:
160
-
161
- - `rule create` / `rule improve` — exits with a pointer to
162
- `taskless help <topic> --anonymous`. The local-only flow runs in the agent
163
- per the recipe variant.
164
- - `info` — skips the API/auth probe; reports local state only.
165
- - `auth login` — rejected (auth commands cannot be anonymous).
166
- - All others — accepted as no-op.
167
-
168
- ## For skill authors
169
-
170
- Skills should detect the package manager by checking for lock files and invoke the CLI accordingly:
171
-
172
- 1. If `pnpm-lock.yaml` exists, use `pnpm dlx @taskless/cli@latest <command>`
173
- 2. Otherwise, use `npx @taskless/cli@latest <command>`
174
-
175
- All commands output structured JSON to stdout by default. Parse with `JSON.parse()` and handle non-zero exit codes as errors.
176
-
177
- ## Developing
178
-
179
- ### Testing
89
+ The CLI reports anonymous usage (which command ran, whether it succeeded, how
90
+ long it took, and counts of findings. It never sends rule content, prompts, or
91
+ matched source. Disable it by setting either environment variable:
180
92
 
181
93
  ```bash
182
- pnpm --filter @taskless/cli test # run the suite once
183
- pnpm --filter @taskless/cli exec vitest # watch mode
94
+ export TASKLESS_TELEMETRY_DISABLED=1
95
+ # or the cross-tool convention
96
+ export DO_NOT_TRACK=1
184
97
  ```
185
98
 
186
- The suite runs entirely locally under vitest no network, no auth, no agent.
187
- Integration tests that exercise the built binary (for example `detect`) run
188
- against `dist/`, so run `pnpm --filter @taskless/cli build` first (or after any
189
- source change) before invoking them directly.
190
-
191
- **Two kinds of test, one of which is not fully automatable.** Most tests are
192
- deterministic unit/integration checks. The route-honesty dataset
193
- (`test/fixtures/route-eval.json`) is different: the actual routing decision is
194
- made by an _agent_ following `help/route.txt`, so it cannot be asserted by a
195
- code classifier. The automated test (`test/route-eval.test.ts`) therefore only
196
- **guards the dataset** — that it stays structurally valid and balanced across
197
- every route and both failure directions (over-claim / over-escalate). Running
198
- the dataset _as an evaluation_ — feeding each case to an agent and scoring its
199
- chosen destination — is a separate, manual calibration step with more setup; it
200
- is not part of `pnpm test`. Keep the two distinct: the suite proves the fixtures
201
- are well-formed; an agent run proves the recipe routes honestly.
202
-
203
- ### API base URL
204
-
205
- The CLI resolves the API base URL in this order:
99
+ With either set, no client is created and no network request is made.
206
100
 
207
- 1. `TASKLESS_API_URL` env var
208
- 2. `~/.config/taskless/config.json` → `apiUrl` field
209
- 3. Default: `https://app.taskless.io/cli`
101
+ </details>
210
102
 
211
- For local development against the taskless.io app:
212
-
213
- ```bash
214
- TASKLESS_API_URL=http://localhost:5173/cli taskless info
215
- ```
216
-
217
- ### API schema introspection
218
-
219
- All `/cli/api/*` endpoints support the `x-explain: 1` header. When present, the endpoint returns its JSON schema instead of executing — no authentication required.
220
-
221
- ```bash
222
- # List available endpoints
223
- curl -s -H "x-explain: 1" http://localhost:5173/cli/api
224
-
225
- # Get the schema for rule generation
226
- curl -s -H "x-explain: 1" -X POST http://localhost:5173/cli/api/rule
227
-
228
- # Get the schema for rule status polling
229
- curl -s -H "x-explain: 1" http://localhost:5173/cli/api/rule/any-id
230
-
231
- # Get the schema for whoami
232
- curl -s -H "x-explain: 1" http://localhost:5173/cli/api/whoami
233
- ```
103
+ ---
234
104
 
235
- This is useful for verifying that CLI types align with the production API contract.
105
+ MIT licensed.
@@ -0,0 +1,213 @@
1
+ {
2
+ "version": 2,
3
+ "protocol": [
4
+ "Generate a rule from `prompt`, using your own pipeline.",
5
+ "Run `taskless verify` over what you generated. It enforces constraints beyond the engine's own schema, listed in `constraints` below, so a rule the engine executes correctly can still be refused. A rule that fails here is not deliverable however well it behaves, and every later step would be measuring the wrong thing. Check `enforcedBy` before concluding anything: some constraints are only decided once the fixtures run.",
6
+ "Run your generated rule against your own cases. It should pass; if it does not, the disagreement is inside your pipeline and nothing below will be informative.",
7
+ "Run your generated rule against `tests` here. A failure means your rule and ours disagree about the subject, and `tests` is the arbiter.",
8
+ "Run the rule in `rule` here against your cases. A failure means your cases and ours disagree, which is worth as much as the previous step and is the one nobody runs."
9
+ ],
10
+ "layout": {
11
+ "rulesRoot": ".taskless/rules",
12
+ "ruleDirectory": ".taskless/rules/{engine}/{id}",
13
+ "testsDirectory": ".tests",
14
+ "engines": {
15
+ "sg": {
16
+ "ruleFile": "{id}.yml",
17
+ "ruleConfigFile": null,
18
+ "capturesDirectory": null,
19
+ "fixtureLayout": "ast-grep-test"
20
+ },
21
+ "vale": {
22
+ "ruleFile": "{id}.yml",
23
+ "ruleConfigFile": ".vale.ini",
24
+ "capturesDirectory": null,
25
+ "fixtureLayout": "case-documents"
26
+ },
27
+ "runtime": {
28
+ "ruleFile": "check.ts",
29
+ "ruleConfigFile": null,
30
+ "capturesDirectory": "captures",
31
+ "fixtureLayout": "case-directories"
32
+ }
33
+ }
34
+ },
35
+ "constraints": [
36
+ {
37
+ "id": "sg-id-matches-directory",
38
+ "engine": "sg",
39
+ "enforcedBy": "verify",
40
+ "summary": "A rule's `id:` must equal the directory it lives in.",
41
+ "rationale": "The directory name is the rule id: it is what `check` and `test` address, and what a person types to delete a rule. ast-grep registers the rule under the id in its body. With the two apart, `test` cannot find the rule at all, and `check` does run it but reports findings under a name no directory has, so nobody can locate what produced them."
42
+ },
43
+ {
44
+ "id": "sg-regex-needs-kind",
45
+ "engine": "sg",
46
+ "enforcedBy": "verify",
47
+ "summary": "A `regex` needs a sibling `kind`, in `rule`, `constraints` and `utils`.",
48
+ "rationale": "A regex match with no kind to anchor it is ambiguous and slow: it is applied to every node rather than to the one shape the author meant. ast-grep accepts it, so the engine is not the thing that will tell you."
49
+ },
50
+ {
51
+ "id": "sg-language-accepted",
52
+ "engine": "sg",
53
+ "enforcedBy": "verify",
54
+ "summary": "`language:` must be a spelling ast-grep itself uses; a resolvable but non-canonical one is a notice.",
55
+ "rationale": "An unrecognized name aborts config parsing, which takes every other sg rule in the project down with it and reports nothing. That is the loudest possible failure with the quietest possible symptom: a clean report."
56
+ },
57
+ {
58
+ "id": "sg-files-globs-parse",
59
+ "engine": "sg",
60
+ "enforcedBy": "verify",
61
+ "summary": "`files:` globs must not name `.tsx` under TypeScript, or `.ts` under Tsx.",
62
+ "rationale": "A glob naming an extension the language cannot parse matches nothing, so the rule reports a clean codebase rather than an error. Only the TypeScript/Tsx pair is checked, and deliberately so: they are separate parsers rather than aliases, which is the one language/extension mismatch decidable from the rule file alone. No other extension is compared against `language`, so this is narrower than it first reads."
63
+ },
64
+ {
65
+ "id": "sg-required-fields",
66
+ "engine": "sg",
67
+ "enforcedBy": "verify",
68
+ "summary": "`id`, `language`, `severity`, `message` and `rule` are required.",
69
+ "rationale": "ast-grep needs fewer of these than we do. The extras are what make a finding actionable and a rule addressable once it is on disk."
70
+ },
71
+ {
72
+ "id": "sg-test-file-required",
73
+ "engine": "sg",
74
+ "enforcedBy": "verify",
75
+ "summary": "A rule must ship at least one test file under `.tests/`.",
76
+ "rationale": "A rule with no fixtures has shown neither that it fires nor that it stays quiet. `verify` requires the file; `test` requires the cases inside it to cover both."
77
+ },
78
+ {
79
+ "id": "sg-fixture-id-matches-rule",
80
+ "engine": "sg",
81
+ "enforcedBy": "test",
82
+ "summary": "A test file's own `id:` must equal the rule id.",
83
+ "rationale": "Fixtures are attributed by the id inside the file, not by its name. A fixture carrying another rule's id is silently not counted, so a rule that ships one reads as a rule that shipped none. `verify` passes, because the FILE is there; `test` is where it bites."
84
+ }
85
+ ],
86
+ "rules": [
87
+ {
88
+ "engine": "sg",
89
+ "id": "no-eval-call",
90
+ "directory": ".taskless/rules/sg/no-eval-call",
91
+ "ruleFile": "no-eval-call.yml",
92
+ "prompt": "Create a rule that flags any call to `eval()` in TypeScript.\n\nExecuting a string at runtime is an injection risk, and it defeats every static\nanalysis the project runs: nothing downstream can see what the code will do.\nPrefer a parser, a lookup table, or an explicit dispatch.\n\nDeciding this needs only the expression itself. No other file has to be read,\nand no state has to be resolved.\n\nCode that should NOT be flagged:\n\n```ts\nconst parsed = JSON.parse(payload);\n```\n\nCode that SHOULD be flagged:\n\n```ts\nconst result = eval(payload);\n```\n",
93
+ "rule": [
94
+ {
95
+ "path": "no-eval-call.yml",
96
+ "content": "id: no-eval-call\nlanguage: TypeScript\nseverity: error\nmessage: \"`eval` executes arbitrary code at runtime; use a parser or a lookup table instead.\"\nnote: |-\n This is an example rule, shipped with the Taskless CLI to demonstrate the\n `sg` tier. It was not written for this repository.\n\n The evidence is one expression in one file, which is what makes this an `sg`\n rule rather than a runtime one: no other file has to be read to decide it.\nrule:\n pattern: eval($ARG)\n"
97
+ }
98
+ ],
99
+ "tests": {
100
+ "grouping": "ast-grep-test",
101
+ "files": [
102
+ {
103
+ "path": ".tests/no-eval-call-test.yml",
104
+ "content": "id: no-eval-call\nvalid:\n - const parsed = JSON.parse(payload);\ninvalid:\n - const result = eval(payload);\n"
105
+ }
106
+ ]
107
+ }
108
+ },
109
+ {
110
+ "engine": "vale",
111
+ "id": "prefer-use-over-utilize",
112
+ "directory": ".taskless/rules/vale/prefer-use-over-utilize",
113
+ "ruleFile": "prefer-use-over-utilize.yml",
114
+ "prompt": "Create a rule that flags the word \"utilize\" in markdown documentation and\nsuggests \"use\" instead.\n\n\"Utilize\" is longer than \"use\" and means the same thing in nearly every\nsentence a reader will meet. It should match case-insensitively and cover the\ninflected forms.\n\nThis is a prose rule. It applies to markdown, and it must not look at source\ncode.\n\nProse that should NOT be flagged:\n\n```md\nUse the installer to write the config file.\n```\n\nProse that SHOULD be flagged:\n\n```md\nUtilize the installer to write the config file.\n```\n",
115
+ "rule": [
116
+ {
117
+ "path": "prefer-use-over-utilize.yml",
118
+ "content": "extends: substitution\nmessage: \"Prefer '%s' over '%s'.\"\nlevel: warning\nignorecase: true\nswap:\n utilize: use\n utilizes: uses\n utilizing: using\n"
119
+ },
120
+ {
121
+ "path": ".vale.ini",
122
+ "content": "# Scoped to markdown, which is where prose rules belong. A demonstration rule\n# should never widen its own scope into a project's source.\n[**/*.md]\nBasedOnStyles =\nprefer-use-over-utilize.prefer-use-over-utilize = YES\n"
123
+ }
124
+ ],
125
+ "tests": {
126
+ "grouping": "case-documents",
127
+ "files": [
128
+ {
129
+ "path": ".tests/pass/README.md",
130
+ "content": "# Setup\n\nUse the installer to write the config file.\n"
131
+ },
132
+ {
133
+ "path": ".tests/fail/README.md",
134
+ "content": "# Setup\n\nUtilize the installer to write the config file.\n"
135
+ }
136
+ ],
137
+ "cases": [
138
+ {
139
+ "bucket": "pass",
140
+ "name": "README.md",
141
+ "path": ".tests/pass/README.md",
142
+ "files": [".tests/pass/README.md"]
143
+ },
144
+ {
145
+ "bucket": "fail",
146
+ "name": "README.md",
147
+ "path": ".tests/fail/README.md",
148
+ "files": [".tests/fail/README.md"]
149
+ }
150
+ ]
151
+ }
152
+ },
153
+ {
154
+ "engine": "runtime",
155
+ "id": "env-keys-declared",
156
+ "directory": ".taskless/rules/runtime/env-keys-declared",
157
+ "ruleFile": "check.ts",
158
+ "prompt": "Create a rule that requires every environment variable read through\n`process.env` in a JavaScript or TypeScript file to have a matching key\ndeclared in the `.env` file at the repository root.\n\nReading a variable that is never declared is the failure worth catching: it is\n`undefined` at runtime rather than an error, so the program continues with a\nmissing value and fails somewhere else entirely.\n\nDeciding this needs both files at once — the read and the declaration — so it\ncannot be answered by matching a pattern within a single file.\n\nA repository that should NOT be flagged:\n\n```ts\n// src/config.ts\nexport const apiUrl = process.env.API_URL;\n```\n\n```\n# .env\nAPI_URL=https://api.example.com\n```\n\nA repository that SHOULD be flagged, on the second line only:\n\n```ts\n// src/config.ts\nexport const apiUrl = process.env.API_URL;\nexport const apiKey = process.env.API_KEY;\n```\n\n```\n# .env\nAPI_URL=https://api.example.com\n```\n",
159
+ "rule": [
160
+ {
161
+ "path": "check.ts",
162
+ "content": "/**\n * Every `process.env` read must have a matching key in the repository-root\n * `.env`.\n *\n * This is the demonstration rule, and its subject was chosen for one property:\n * the evidence spans two files. The read is in a source file, the declaration\n * is in `.env`, and no single-file pattern can compare them. A rule whose\n * evidence fits in one file is an `sg` rule, so a single-file subject would\n * demonstrate this tier by exercising none of what makes it a tier.\n *\n * It imports nothing from `@taskless/*`. The harness contract is structural —\n * a default-exported async function taking `(root, matches)` and returning\n * findings — so a delivered check never depends on a package being installed.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** One normalized ast-grep match, as the harness hands it over. */\ninterface Match {\n /** The capture rule's stable name, which is what a check branches on. */\n rule: string;\n /** Path relative to `root`. */\n file: string;\n line: number;\n column: number;\n /** Captured metavariables by name. */\n captures: Record<string, string>;\n}\n\n/** One result the check returns. */\ninterface Finding {\n file: string;\n line?: number;\n column?: number;\n message: string;\n severity?: \"error\" | \"warning\" | \"info\";\n}\n\n/**\n * Keys declared in the repository-root `.env`, or none when it is absent.\n *\n * A missing `.env` yields an empty set rather than an error: a project with no\n * `.env` at all has declared nothing, which is exactly what the empty set\n * means, and every read in it is then correctly reported.\n */\nfunction declaredKeys(root: string): Set<string> {\n let raw: string;\n try {\n raw = readFileSync(join(root, \".env\"), \"utf8\");\n } catch {\n return new Set();\n }\n\n const keys = new Set<string>();\n for (const line of raw.split(\"\\n\")) {\n const text = line.trim();\n if (text === \"\" || text.startsWith(\"#\") || text.startsWith(\"//\")) continue;\n const equals = text.indexOf(\"=\");\n // `<= 0` rather than `=== -1`: a line beginning with `=` has no name.\n if (equals <= 0) continue;\n keys.add(text.slice(0, equals).trim());\n }\n return keys;\n}\n\nexport default async function check(\n root: string,\n matches: Match[]\n): Promise<Finding[]> {\n const declared = declaredKeys(root);\n const findings: Finding[] = [];\n\n for (const match of matches) {\n // Branch on the capture rule's NAME, never on its id: the id is a\n // baked-in hash and is opaque to a check.\n if (match.rule !== \"env-read\") continue;\n\n const name = match.captures.VAR;\n if (name === undefined || declared.has(name)) continue;\n\n findings.push({\n file: match.file,\n line: match.line,\n column: match.column,\n message: `process.env.${name} is read here, but ${name} is not declared in .env`,\n severity: \"error\",\n });\n }\n\n return findings;\n}\n"
163
+ },
164
+ {
165
+ "path": "captures/env-read.yml",
166
+ "content": "# The syntactic narrow. It finds every `process.env.X` read; it cannot know\n# whether X is declared, because that answer lives in a different file. Deciding\n# is `check.ts`'s job, which is what makes this a runtime rule rather than an\n# `sg` one.\nid: env-read\nlanguage: TypeScript\nrule:\n pattern: process.env.$VAR\nmetadata:\n taskless:\n version: 1\n kind: runtime\n name: env-read\n check: check.ts\n match: anchor\n"
167
+ }
168
+ ],
169
+ "tests": {
170
+ "grouping": "case-directories",
171
+ "files": [
172
+ {
173
+ "path": ".tests/pass/declared/src/config.ts",
174
+ "content": "// API_URL is read here and declared in .env, so this case is clean.\nexport const apiUrl = process.env.API_URL;\n"
175
+ },
176
+ {
177
+ "path": ".tests/pass/declared/.env",
178
+ "content": "API_URL=https://api.example.com\n"
179
+ },
180
+ {
181
+ "path": ".tests/fail/undeclared/src/config.ts",
182
+ "content": "// API_URL is declared; API_KEY is not, so the rule fires on the second.\nexport const apiUrl = process.env.API_URL;\nexport const apiKey = process.env.API_KEY;\n"
183
+ },
184
+ {
185
+ "path": ".tests/fail/undeclared/.env",
186
+ "content": "API_URL=https://api.example.com\n"
187
+ }
188
+ ],
189
+ "cases": [
190
+ {
191
+ "bucket": "pass",
192
+ "name": "declared",
193
+ "path": ".tests/pass/declared",
194
+ "files": [
195
+ ".tests/pass/declared/src/config.ts",
196
+ ".tests/pass/declared/.env"
197
+ ]
198
+ },
199
+ {
200
+ "bucket": "fail",
201
+ "name": "undeclared",
202
+ "path": ".tests/fail/undeclared",
203
+ "files": [
204
+ ".tests/fail/undeclared/src/config.ts",
205
+ ".tests/fail/undeclared/.env"
206
+ ]
207
+ }
208
+ ]
209
+ },
210
+ "signature": "1;h=sha-256;d=0000000000000000000000000000000000000000000000000000000000000000"
211
+ }
212
+ ]
213
+ }
@@ -0,0 +1,107 @@
1
+ import { spawnSync as v } from "node:child_process";
2
+ import { existsSync as u } from "node:fs";
3
+ import { createRequire as g } from "node:module";
4
+ import { resolve as i, dirname as c } from "node:path";
5
+ import { fileURLToPath as y } from "node:url";
6
+ const R = {
7
+ label: "ast-grep",
8
+ packagePrefix: "@ast-grep/cli",
9
+ toolchainSuffix: !0,
10
+ binaryNames: ["ast-grep", "sg"],
11
+ identity: /ast-grep/i
12
+ };
13
+ function m(e) {
14
+ const r = [process.platform, process.arch];
15
+ return e.toolchainSuffix && (process.platform === "linux" ? r.push("gnu") : process.platform === "win32" && r.push("msvc")), `${e.packagePrefix}-${r.join("-")}`;
16
+ }
17
+ function o(e) {
18
+ return process.platform === "win32" ? `${e}.exe` : e;
19
+ }
20
+ function p(e) {
21
+ return e.binaryNames.toReversed();
22
+ }
23
+ function b(e) {
24
+ return o(p(e)[0] ?? e.label);
25
+ }
26
+ function P(e, r) {
27
+ try {
28
+ const a = g(import.meta.url).resolve(
29
+ `${m(e)}/package.json`
30
+ );
31
+ return i(c(a), r);
32
+ } catch {
33
+ return;
34
+ }
35
+ }
36
+ function h(e) {
37
+ const r = process.platform === "win32" ? ";" : ":";
38
+ for (const t of (process.env.PATH ?? "").split(r)) {
39
+ if (t === "") continue;
40
+ const a = i(t, e);
41
+ if (u(a)) return a;
42
+ }
43
+ }
44
+ function k(e, r) {
45
+ if (!u(r)) return !1;
46
+ const t = v(r, ["--version"], {
47
+ encoding: "utf8",
48
+ timeout: 5e3
49
+ });
50
+ return t.error !== void 0 || t.status !== 0 ? !1 : e.identity.test(`${t.stdout ?? ""}${t.stderr ?? ""}`);
51
+ }
52
+ function x(e) {
53
+ const r = i(
54
+ c(y(import.meta.url)),
55
+ "..",
56
+ "node_modules",
57
+ ".bin"
58
+ ), t = p(e), a = [
59
+ [
60
+ "platform-package",
61
+ m(e),
62
+ P(
63
+ e,
64
+ o(e.binaryNames[0] ?? e.label)
65
+ )
66
+ ],
67
+ ...t.map(
68
+ (n) => [
69
+ "node_modules/.bin",
70
+ "node_modules/.bin",
71
+ i(r, o(n))
72
+ ]
73
+ ),
74
+ ...t.map(
75
+ (n) => ["PATH", "PATH", h(o(n))]
76
+ )
77
+ ], l = [...new Set(a.map(([, n]) => n))];
78
+ for (const [n, , s] of a)
79
+ if (s !== void 0 && k(e, s))
80
+ return { path: s, source: n, tried: l };
81
+ return { path: void 0, source: void 0, tried: l };
82
+ }
83
+ const d = {
84
+ label: "vale",
85
+ packagePrefix: "@taskless/vale",
86
+ toolchainSuffix: !1,
87
+ binaryNames: ["vale"],
88
+ identity: /vale/i
89
+ };
90
+ let f;
91
+ function T() {
92
+ return f ??= x(d), f;
93
+ }
94
+ function _(e) {
95
+ return `Vale binary not found. Looked in: ${e.join(", ")}. Install a supported platform build, or put \`${b(d)}\` on your PATH. Other engines still ran.`;
96
+ }
97
+ export {
98
+ R as A,
99
+ d as V,
100
+ m as a,
101
+ T as b,
102
+ h as f,
103
+ k as i,
104
+ b as p,
105
+ x as r,
106
+ _ as v
107
+ };