diff --git a/.changeset/pinned-cli-permissions.md b/.changeset/pinned-cli-permissions.md index e47c9aa0..4060f254 100644 --- a/.changeset/pinned-cli-permissions.md +++ b/.changeset/pinned-cli-permissions.md @@ -5,3 +5,5 @@ Released builds now pin their own version. The installed skill and the `agent` recipes name `npx @taskless/cli@` instead of the unpinned `npx @taskless/cli` or `@latest`, as nightlies already did, so an agent runs exactly the version the project installed. Commands the CLI prints for you, such as an authentication remedy, are pinned the same way. Upgrading is unchanged: re-run `init`. Two places stay unpinned: a reference stub's reinstall line, so stubs do not change on every release, and `.taskless/README.md`, which is written once and read across upgrades. `init` now also reports stale Taskless pins in CI and git-hook files (GitHub Actions, GitLab CI, CircleCI, husky, lefthook, pre-commit, and the rest of the files `detect` recognizes) by file and line, through the same notice and `pinnedCli` JSON field as `package.json` pins. It recommends the version to move to and never edits the file. + +`init` now also allows exactly the installed CLI in each agent it installs for, so a harness that gates download-and-run commands lets Taskless through without a prompt: Claude Code (`.claude/settings.json`), Cursor (`.cursor/permissions.json` and `.cursor/cli.json`), OpenCode (`opencode.json[c]`), and Codex (`.codex/rules/taskless.rules`, when Codex is present). Each rule names one version and cannot match another. Every edit is announced, as `Edited ` with a green `ALLOW:` line, and the wizard shows it and asks before writing. An upgrade replaces only the rule Taskless recorded, a rule you wrote is never touched, and a file that denies the command or does not parse is left alone and reported. `init --json` carries the same edits under `permissions`. Agents Taskless does not install into use per-command approval through the skill. diff --git a/openspec/changes/pinned-cli-permissions/design.md b/openspec/changes/archive/2026-10-15-pinned-cli-permissions/design.md similarity index 100% rename from openspec/changes/pinned-cli-permissions/design.md rename to openspec/changes/archive/2026-10-15-pinned-cli-permissions/design.md diff --git a/openspec/changes/pinned-cli-permissions/proposal.md b/openspec/changes/archive/2026-10-15-pinned-cli-permissions/proposal.md similarity index 100% rename from openspec/changes/pinned-cli-permissions/proposal.md rename to openspec/changes/archive/2026-10-15-pinned-cli-permissions/proposal.md diff --git a/openspec/changes/pinned-cli-permissions/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-15-pinned-cli-permissions/specs/cli-init/spec.md similarity index 100% rename from openspec/changes/pinned-cli-permissions/specs/cli-init/spec.md rename to openspec/changes/archive/2026-10-15-pinned-cli-permissions/specs/cli-init/spec.md diff --git a/openspec/changes/pinned-cli-permissions/specs/cli/spec.md b/openspec/changes/archive/2026-10-15-pinned-cli-permissions/specs/cli/spec.md similarity index 100% rename from openspec/changes/pinned-cli-permissions/specs/cli/spec.md rename to openspec/changes/archive/2026-10-15-pinned-cli-permissions/specs/cli/spec.md diff --git a/openspec/changes/pinned-cli-permissions/specs/infrastructure/spec.md b/openspec/changes/archive/2026-10-15-pinned-cli-permissions/specs/infrastructure/spec.md similarity index 100% rename from openspec/changes/pinned-cli-permissions/specs/infrastructure/spec.md rename to openspec/changes/archive/2026-10-15-pinned-cli-permissions/specs/infrastructure/spec.md diff --git a/openspec/changes/pinned-cli-permissions/specs/skill-taskless/spec.md b/openspec/changes/archive/2026-10-15-pinned-cli-permissions/specs/skill-taskless/spec.md similarity index 100% rename from openspec/changes/pinned-cli-permissions/specs/skill-taskless/spec.md rename to openspec/changes/archive/2026-10-15-pinned-cli-permissions/specs/skill-taskless/spec.md diff --git a/openspec/changes/pinned-cli-permissions/tasks.md b/openspec/changes/archive/2026-10-15-pinned-cli-permissions/tasks.md similarity index 77% rename from openspec/changes/pinned-cli-permissions/tasks.md rename to openspec/changes/archive/2026-10-15-pinned-cli-permissions/tasks.md index e69a6de3..9747eb28 100644 --- a/openspec/changes/pinned-cli-permissions/tasks.md +++ b/openspec/changes/archive/2026-10-15-pinned-cli-permissions/tasks.md @@ -11,10 +11,10 @@ - [x] 2.1 Hand test: under Claude Code auto mode, confirm `Bash(npx @taskless/cli-nightly@ *)` in `.claude/settings.json` survives entry and lets `agent ok` run, in a trusted workspace - [x] 2.2 Hand test Codex: `codex execpolicy check --rules .codex/rules/taskless.rules -- check` allows, and `1 check` does not -- [ ] 2.3 A writer per format: Claude Code, Cursor editor and CLI, OpenCode object (last) and array (first), JSONC-preserving, and Codex -- [ ] 2.4 Deny detection and unparseable-file refusal per format -- [ ] 2.5 `install.permissions` in the manifest; upgrade replacement; delete a Taskless-created file left empty -- [ ] 2.6 Report every edit: `Created`/`Edited ` with green `ALLOW:`, red `REMOVE:`, yellow `SKIPPED:` lines via `util/color.ts`; preview in the wizard summary step before writing; print after writing in `--no-interactive`; stderr plus envelope data under `--json` -- [ ] 2.7 Preflight wording: per-command approval only where a harness cannot scope a rule to one invocation -- [ ] 2.8 Tests per format: merge, placement, no longer-version match, deny, malformed, upgrade, user-owned rule untouched -- [ ] 2.9 Update the `init` recipe; changeset; run `pnpm typecheck` and `pnpm lint` +- [x] 2.3 A writer per format: Claude Code, Cursor editor and CLI, OpenCode object (last) and array (first), JSONC-preserving, and Codex +- [x] 2.4 Deny detection and unparseable-file refusal per format +- [x] 2.5 `install.permissions` in the manifest; upgrade replacement; delete a Taskless-created file left empty +- [x] 2.6 Report every edit: `Created`/`Edited ` with green `ALLOW:`, red `REMOVE:`, yellow `SKIPPED:` lines via `util/color.ts`; preview in the wizard summary step before writing; print after writing in `--no-interactive`; stderr plus envelope data under `--json` +- [x] 2.7 Preflight wording: per-command approval only where a harness cannot scope a rule to one invocation +- [x] 2.8 Tests per format: merge, placement, no longer-version match, deny, malformed, upgrade, user-owned rule untouched +- [x] 2.9 Update the `init` recipe; changeset; run `pnpm typecheck` and `pnpm lint` diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index c64cf1d0..718fcbbf 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -897,3 +897,157 @@ The `taskless init --json` envelope SHALL report each removed command in that ta - **WHEN** `taskless init --json` removes a recorded command - **THEN** the affected target's `removedCommands` SHALL list `tskl.md` + +### Requirement: Init names a CI or hook pin older than the running CLI + +Alongside the `package.json` pins it already reports, `taskless init` SHALL read the project's CI and git-hook configuration files and report every pin of `@taskless/cli` or `@taskless/cli-nightly` in them that would run a CLI older than the running one. Every build pins its own version, so the invocation the `ci` and `hooks` recipes write names that version, and an upgrade leaves it behind. + +The files read SHALL be the ones `detect` recognizes as CI and commit-time configuration at the project root: `.github/workflows/*.yml` and `*.yaml`, `.gitlab-ci.yml`, `.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml` and `.yaml`, `bitbucket-pipelines.yml`, `.drone.yml`, `.travis.yml`, the files directly in `.buildkite/` and `.husky/`, the lefthook, pre-commit, simple-git-hooks, and lint-staged config files. They SHALL be read as text with the script-pin grammar, a pin repeated in one file SHALL be reported once at its first line, and only a bounded spec SHALL be judged, as for a script. + +Each pin SHALL be reported through the same notice and the same `pinnedCli` envelope entry as a `package.json` pin, with `manifest` the file's root-relative `/`-separated path, `location` `line `, and `installed` `null`, after every `package.json` pin, ordered by path. The notice SHALL introduce its list without naming `package.json` alone. The install SHALL NOT modify any of these files. + +#### Scenario: A stale workflow pin is named by file and line + +- **WHEN** `.github/workflows/taskless.yml` runs `npx @taskless/cli@0.11.0 check` on line 4 and the running CLI is `0.12.0` +- **THEN** `pinnedCli` SHALL contain `{ "manifest": ".github/workflows/taskless.yml", "location": "line 4", "name": "@taskless/cli", "spec": "0.11.0", "installed": null }` +- **AND** the notice SHALL name `.github/workflows/taskless.yml line 4` and the target `@taskless/cli@0.12.0` +- **AND** the workflow file SHALL be byte-identical afterwards + +#### Scenario: Hook files are read + +- **WHEN** `.husky/pre-commit` or `lefthook.yml` spells out an older `@taskless/cli` or `@taskless/cli-nightly` version +- **THEN** each SHALL be reported with its path + +#### Scenario: A current or floating CI pin is not reported + +- **WHEN** a CI file runs `@taskless/cli` at the running version or at `latest` +- **THEN** no pin SHALL be reported for it + +### Requirement: Init allows the installed CLI in each harness's permission file + +`taskless init`, in the wizard and in `--no-interactive`, SHALL write a rule allowing exactly the installed CLI invocation to the project permission file of each harness it installs the skill for, from this table: + +| Harness | When | File | Rule | +| --------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | `.claude/` selected | `.claude/settings.json` | `permissions.allow`: `Bash( *)` | +| Cursor (editor) | `.cursor/` selected | `.cursor/permissions.json` | `terminalAllowlist`: `` | +| Cursor (CLI) | `.cursor/` selected | `.cursor/cli.json` | `permissions.allow`: `Shell(npx:)` and `Shell(npx: *)` | +| OpenCode | `.opencode/` selected | the existing `opencode.json` or `opencode.jsonc`, at the root or under `.opencode/`, else `.opencode/opencode.json` | object form: `permission.bash[" *"]: "allow"`; array form: `{ "action": "shell", "resource": " *", "effect": "allow" }` | +| Codex | `.agents/` selected and Codex detected | `.codex/rules/taskless.rules` | `prefix_rule(pattern=["npx", ""], decision="allow")` | + +Each rule SHALL use that harness's word-bounded form, so it matches the invocation alone or followed by arguments and SHALL NOT match a longer version string. No rule SHALL wildcard the package name or version. The generic `.agents/` target has no permission file and SHALL receive none. The table is exhaustive: `init` SHALL NOT write a permission rule for any harness not listed in it. Harnesses that read the skill from `.agents/` (other than Codex), harnesses with no file-based allowlist such as Cline, and harnesses whose allowlist can only allow a whole tool such as Crush SHALL receive none. + +In OpenCode's object form the entry SHALL be placed after every existing key, since the last matching rule wins; in the array form it SHALL be placed first. + +How each edit is reported is defined by the requirement that init reports every permission edit. + +#### Scenario: An unlisted harness gets no rule + +- **WHEN** `init` runs in a project with a harness configuration directory that is not in the table +- **THEN** `init` SHALL NOT create or edit any file in it + +#### Scenario: Claude Code gets a pinned rule + +- **WHEN** `init` installs for `.claude/` with invocation `npx @taskless/cli@0.13.0` +- **THEN** `.claude/settings.json` SHALL contain `Bash(npx @taskless/cli@0.13.0 *)` in `permissions.allow` +- **AND** every other key and entry in the file SHALL be unchanged + +#### Scenario: The rule does not match another version + +- **WHEN** the rule for `npx @taskless/cli@0.13.0` is written for any harness +- **THEN** under that harness's documented matching it SHALL NOT match `npx @taskless/cli@0.13.01` + +#### Scenario: OpenCode object form puts the rule last + +- **WHEN** `opencode.json` has `permission.bash` with `"*": "ask"` +- **THEN** the Taskless key SHALL follow `"*"` in the written object + +#### Scenario: OpenCode array form puts the rule first + +- **WHEN** the OpenCode file uses the array form under `permissions` +- **THEN** the Taskless entry SHALL be the first element + +#### Scenario: Codex gets a rules file only when detected + +- **WHEN** `.agents/` is selected and no Codex detection signal is present +- **THEN** `init` SHALL NOT create `.codex/` + +#### Scenario: OpenCode comments survive + +- **WHEN** the OpenCode file is `opencode.jsonc` with comments +- **THEN** the written file SHALL keep every comment + +### Requirement: Init reports every permission edit + +Every time `init` edits or creates a harness permission file, its output SHALL name the file and each rule it changed, one per line, labelled so the reader cannot miss that a settings file was touched: + +``` +Edited .claude/settings.json + ALLOW: Bash(npx @taskless/cli@0.13.1 *) + REMOVE: Bash(npx @taskless/cli@0.13.0 *) +``` + +- `ALLOW: ` for a rule added, rendered in green. +- `REMOVE: ` for a recorded rule taken out on upgrade, rendered in red. +- `SKIPPED: ` with the reason, for a file left alone because it denies the invocation or does not parse, rendered in yellow. + +The first line SHALL say `Created` for a file Taskless created and `Edited` for one that existed. The labels SHALL carry the meaning on their own, so the output reads the same with color off (`NO_COLOR`, no TTY). Color SHALL come from the CLI's shared color setup, so it follows the real terminal. + +The wizard SHALL show the same lines in its summary step, before anything is written, so the user sees each permission edit before confirming. `--no-interactive` SHALL print them after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. + +#### Scenario: A first install names the edit + +- **WHEN** `init --no-interactive` adds a rule to an existing `.claude/settings.json` +- **THEN** the output SHALL include `Edited .claude/settings.json` followed by `ALLOW: Bash( *)` +- **AND** with color on, the `ALLOW` line SHALL be green + +#### Scenario: An upgrade shows both sides + +- **WHEN** `init` replaces a recorded `0.13.0` rule with the `0.13.1` rule +- **THEN** the output SHALL list `ALLOW:` for the `0.13.1` rule and `REMOVE:` for the `0.13.0` rule under the file's name + +#### Scenario: The wizard previews before writing + +- **WHEN** the wizard reaches its summary step for an install that will edit `.cursor/cli.json` +- **THEN** the summary SHALL show the file and its `ALLOW:` lines before the user confirms +- **AND** nothing SHALL be written if the user cancels + +#### Scenario: Color off still reads + +- **WHEN** `NO_COLOR` is set +- **THEN** the lines SHALL carry the same `ALLOW:`, `REMOVE:`, and `SKIPPED:` labels with no escape sequences + +#### Scenario: JSON carries the edits as data + +- **WHEN** `init --json` edits a permission file +- **THEN** the envelope SHALL report that file's path, whether it was created, and the exact rules allowed and removed + +### Requirement: Init never overrides a deny or a file it cannot read + +`init` SHALL NOT write a rule to a harness file that denies the invocation, or that holds a rule which would take precedence over the Taskless rule and refuse it. It SHALL NOT write to a file that does not parse. In each case it SHALL leave the file unchanged and name it in the summary with the reason. + +#### Scenario: A user deny is respected + +- **WHEN** `.claude/settings.json` denies `Bash(npx *)` +- **THEN** `init` SHALL NOT add the allow rule +- **AND** the summary SHALL say the file denies the invocation + +#### Scenario: A malformed file is not overwritten + +- **WHEN** `.cursor/cli.json` does not parse +- **THEN** `init` SHALL leave it byte-identical and report it + +### Requirement: The manifest records permission rules, and an upgrade replaces only those + +The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. On a later `init` the installer SHALL remove exactly the recorded entries and write the current ones. It SHALL NOT remove or edit any entry it did not record, including a rule for Taskless the user wrote. A file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. + +#### Scenario: An upgrade swaps the version + +- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json` and `init` runs at `0.13.1` +- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.1 *)` and not the `0.13.0` rule +- **AND** the manifest SHALL record only the `0.13.1` rule + +#### Scenario: A user's own rule survives an upgrade + +- **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record +- **THEN** an upgrade SHALL leave that entry in place diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index 9d2c0dac..16a8ddc4 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -531,7 +531,7 @@ Yarn and bun are deliberately not detected. They are distinguishable only by the Wherever the CLI prints a command for the user to run — an authentication prompt, a re-authentication hint, an error remedy — it SHALL compose that command from the detected launcher and from the package specifier of the build in hand, not from a hardcoded string. -The package specifier SHALL come from the build-target invocation, so a nightly build names `@taskless/cli-nightly` at its published version and a prod build names `@taskless/cli`. A prod specifier SHALL be pinned to `@latest` when it is handed to a launcher, since `npx` and `pnpm dlx` otherwise prefer whatever is already cached. A build whose invocation is a filesystem path (`dev`, `self`) SHALL be printed verbatim, since no launcher applies to it. +The package specifier SHALL come from the build-target invocation, so a nightly build names `@taskless/cli-nightly` at its published version and a prod build names `@taskless/cli` at its released version. Neither SHALL be handed to a launcher as `@latest` or unpinned: the printed command runs the version the project installed, which is also the version its permission rules allow. A build whose invocation is a filesystem path (`dev`, `self`) SHALL be printed verbatim, since no launcher applies to it. Where detection reports unknown, the printed command SHALL name `npx` with the correct package specifier. That is a display default for a human reader who needs something runnable, and it is distinct from the recipe renderer's marker, which is read by an agent that can be asked to supply the right answer. @@ -540,6 +540,12 @@ Where detection reports unknown, the printed command SHALL name `npx` with the c - **WHEN** a nightly build prints an authentication remedy - **THEN** the printed command SHALL name `@taskless/cli-nightly` at the nightly's own version, not `@taskless/cli` +#### Scenario: A release names its own version + +- **WHEN** a prod build prints an authentication remedy +- **THEN** the printed command SHALL name `@taskless/cli@` at the build's own version +- **AND** SHALL NOT name `@taskless/cli@latest` + #### Scenario: A pnpm script no longer suggests pnpm dlx - **WHEN** the CLI runs from a `package.json` script under pnpm and prints an authentication remedy diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md index 620370ab..19b3da24 100644 --- a/openspec/specs/infrastructure/spec.md +++ b/openspec/specs/infrastructure/spec.md @@ -439,12 +439,18 @@ Publishing workflows SHALL NOT be serialized merely because they publish. Where ### Requirement: The CLI has three build targets -The CLI SHALL support exactly three build targets, selected by `TASKLESS_BUILD_TARGET`: `prod` (the default, emitting `dist/` with the published `npx @taskless/cli` invocation), `self` (emitting `dist-self/` with a repo-root-relative path, for dogfooding inside this repository), and `nightly` (emitting `dist/` with `npx @taskless/cli-nightly@`). +The CLI SHALL support exactly three build targets, selected by `TASKLESS_BUILD_TARGET`: `prod` (the default, emitting `dist/` with the published invocation pinned to the build's own version, `npx @taskless/cli@`), `self` (emitting `dist-self/` with a repo-root-relative path, for dogfooding inside this repository), and `nightly` (emitting `dist/` with `npx @taskless/cli-nightly@`). There SHALL NOT be a build target that emits an invocation runnable from outside this repository's checkout. Exercising unreleased behavior elsewhere is served by a published nightly, which resolves on any machine and is pinned to the build whose instructions it carries, rather than by an absolute filesystem path baked into shipped content. An unset or unrecognized `TASKLESS_BUILD_TARGET` SHALL resolve to `prod`. The retired value `dev` SHALL be an exception: it SHALL fail the build with an error naming the target as removed, because a caller who sets it is looking for an output directory that will not be written, and a silent fall back to `prod` would surface only as a missing file at a path nothing else mentions. +#### Scenario: The prod target pins its version + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET` unset +- **THEN** the baked invocation SHALL be `npx @taskless/cli@`, where `` is the version the build reports +- **AND** whether a build is the released one SHALL be read from its build target, not by comparing its invocation to an unpinned string + #### Scenario: The self target stays repo-relative - **WHEN** a build runs with `TASKLESS_BUILD_TARGET=self` diff --git a/openspec/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index 40c91ce0..9d753ad4 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -160,14 +160,14 @@ The skill body's topic table SHALL map a request to make, publish, regenerate, o ### Requirement: Skill preflights the CLI before the first Taskless command -The skill body SHALL instruct the agent to run `%(TASKLESS_CLI)s agent ok` once per session, before any other Taskless command, and to proceed only once it prints its confirmation line. Once it does, the agent SHALL go straight to the user's request, without surveying the install or offering a menu of topics. +The skill body SHALL instruct the agent to run `%(TASKLESS_CLI)s agent ok` once per session, before any other Taskless command, and to proceed only once it prints its confirmation line. Once it does, the agent SHALL go straight to the user's request, without surveying the install or offering a menu of topics. On an install whose harness permission rule is in place the preflight passes without a prompt; the guidance below is the fallback for when it does not. When the preflight is blocked by the harness or the user, the body SHALL instruct the agent to: 1. Stop before doing any Taskless work, and not attempt to work around the block. 2. Send one message that names the exact command, and says it downloads and runs a package from npm. 3. Offer the user three ways forward: the agent adds an allow rule for exactly the invocation the skill names to the agent's permission settings, and if the harness blocks that edit too, gives the user the change to make themselves; the user approves it through their agent's permissions command (in Claude Code, `/permissions` with the rule `Bash(:*)`); or the user approves every Taskless command manually, in chat, as the agent runs it. -4. Never propose an allow rule that widens the invocation, such as a wildcard over the package name or its version. +4. Never propose an allow rule that widens the invocation, such as a wildcard over the package name or its version. Where the harness's allowlist cannot scope to the one invocation, for example one that only allows a whole tool, offer per-command approval in chat instead of a rule. 5. Not ask in that message what the user wants Taskless to do. Their request stands, and the agent carries it out once the command is unblocked. #### Scenario: Skill body runs the preflight first @@ -183,6 +183,7 @@ When the preflight is blocked by the harness or the user, the body SHALL instruc - **AND** SHALL offer the agent's permissions command, and per-command approval in chat - **AND** SHALL NOT offer running the command with `!`, which unblocks only the preflight - **AND** SHALL forbid an allow rule that wildcards the package name or version +- **AND** SHALL offer only per-command approval where the harness cannot allow the one invocation without allowing a whole tool - **AND** SHALL forbid asking what the user wants Taskless to do in that message ### Requirement: A request that names no task routes to onboarding diff --git a/packages/cli/package.json b/packages/cli/package.json index ec94102b..a9de1b17 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -66,6 +66,7 @@ "chalk": "^5.6.2", "citty": "^0.1.6", "jose": "^6.2.2", + "jsonc-parser": "^3.3.1", "openapi-fetch": "^0.17.0", "openapi-typescript": "^7.13.0", "posthog-node": "^5.28.11", diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 8922e46d..81424d22 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -1,4 +1,4 @@ -# Topic: init (CLI v%(CLI_VERSION)s / topic v7) +# Topic: init (CLI v%(CLI_VERSION)s / topic v8) ## Goal Install or update the Taskless skill in this project, and migrate the @@ -45,6 +45,11 @@ first step, not the whole job. { "manifest": "packages/app/package.json", "location": "devDependencies", "name": "@taskless/cli", "spec": "^0.10.0", "installed": "0.10.2" } + ], + "permissions": [ + { "file": ".claude/settings.json", "created": false, + "allowed": ["Bash(%(TASKLESS_CLI)s *)"], + "removed": [], "skipped": null } ] } ``` @@ -69,15 +74,24 @@ first step, not the whole job. or a CI or hook file such as `.github/workflows/ci.yml`, with `location` the line. It is reported even when `changed` is `false`, because the pin and the project still disagree. + - `permissions` is always present. Each entry is a harness settings + file this run created, edited, or left alone: the rule allowing + exactly this CLI version (`allowed`), the rule for the previous + version it replaced (`removed`), or, when `skipped` is set, why the + file was left as it was (it denies the command, or does not parse). + These files are the user's; name each one when you tell them what to + commit. Without `--json`, the same facts print as prose: a per-target summary, - then a trailer naming the directories that changed and, after a + each permission edit as `Edited ` with `ALLOW:` and `REMOVE:` + lines (or `SKIPPED:` with the reason), then a trailer naming the directories that changed and, after a version move, pointing at `update`, then a notice naming each stale pin and the version to move it to. 2. **Tell the user what needs committing.** Every `targets[].dir` with - a non-empty list, plus `.taskless/` and any `migrated.files` entries, - now holds changes that belong in version control. Name those paths and + a non-empty list, plus `.taskless/`, any `migrated.files` entries, and + every `permissions[].file` without `skipped`, now holds changes that + belong in version control. Name those paths and say what Taskless rewrote and why (a CLI upgrade, a layout migration), so the user can include them in the commit they choose. Do not stage or commit on your own; the git operations are theirs. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 9ab3f0bd..96e24df0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -20,6 +20,11 @@ import { getPinnedCliNotice, type PinnedCli, } from "../install/pinned-cli"; +import { + describePermissionEdits, + syncPermissions, + type PermissionEdit, +} from "../install/permissions"; import { readInstallState } from "../install/state"; import { getTelemetry } from "../telemetry"; import { getCliVersion } from "../wizard/intro"; @@ -41,6 +46,7 @@ import { readManifest } from "../filesystem/manifest"; import type { MigrationReport } from "../filesystem/migrate"; import { TASKLESS_DIRECTORY } from "../rules/vale/formats"; import { CLIError } from "../util/cli-error"; +import { buildInvocation } from "../util/invocation"; import { makeErrorEnvelope } from "../types/errors"; export const initCommand = defineCommand({ @@ -126,6 +132,9 @@ export const initCommand = defineCommand({ // Always present, empty when nothing is stale, for the same reason // `cliVersion.previous` is `null` rather than absent. pinnedCli: result.pinnedCli, + // Every harness permission file this run created, edited, or left + // alone, with the exact rules. Always present, empty when none. + permissions: result.permissions, // Absent when nothing ran, so a caller distinguishes "the tree was // rewritten" from "nothing happened" by presence, never by reading // empty arrays out of it. @@ -327,8 +336,10 @@ async function runNonInteractive( targets: TargetOutcome[]; /** Whether a migration ran or any target wrote or removed anything. */ changed: boolean; - /** `package.json` pins that cannot resolve to the CLI that ran this. */ + /** Pins in `package.json`, CI, or hooks that run an older CLI than this. */ pinnedCli: PinnedCli[]; + /** Harness permission files this run created, edited, or left alone. */ + permissions: PermissionEdit[]; }> { // Under `--json`, stdout carries only the envelope printed by the caller. // This per-target summary is not on that envelope (it is finer-grained than @@ -452,17 +463,29 @@ async function runNonInteractive( } } + const permissions = await syncPermissions(cwd, { + selected: plan.targets + .filter((target) => target.mode === "reference") + .map((target) => target.dir), + codexDetected: detected.some((tool) => tool.name === "Codex"), + invocation: buildInvocation(), + write: true, + }); + for (const line of describePermissionEdits(permissions)) log(line); + return { reloadNotice, migrated, previousCliVersion, cliVersion, targets, + permissions, // A version move rewrites `install.cliVersion` in the manifest, which is // a tracked file, so it is a change even when every skill byte matched. changed: migrated !== undefined || targets.some((target) => targetChanged(target)) || + permissions.some((edit) => edit.skipped === null) || versionMoved({ previousCliVersion, cliVersion }), pinnedCli: await findStalePins(cwd, cliVersion), }; diff --git a/packages/cli/src/filesystem/manifest.ts b/packages/cli/src/filesystem/manifest.ts index 99698819..8c4128f6 100644 --- a/packages/cli/src/filesystem/manifest.ts +++ b/packages/cli/src/filesystem/manifest.ts @@ -39,6 +39,19 @@ export interface TasklessInstallManifest { cliVersion?: string; targets?: Record; onboarded?: boolean; + /** + * Harness permission rules Taskless wrote, keyed by root-relative file. + * Only these are ever removed again, so a rule the user wrote is never + * touched. Read defensively: a hand-edited manifest can hold anything. + */ + permissions?: Record; +} + +/** What Taskless recorded writing to one harness permission file. */ +export interface TasklessPermissionRecord { + rules: string[]; + /** Whether Taskless created the file, and so may delete it once empty. */ + created: boolean; } /** diff --git a/packages/cli/src/install/permissions.ts b/packages/cli/src/install/permissions.ts new file mode 100644 index 00000000..342d6ed0 --- /dev/null +++ b/packages/cli/src/install/permissions.ts @@ -0,0 +1,778 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import chalk from "chalk"; +import { + applyEdits, + findNodeAtLocation, + modify, + parse, + parseTree, + type FormattingOptions, + type ParseError, +} from "jsonc-parser"; + +// Sets `chalk.level` from the real terminal on import. This module renders +// colour, so it establishes that itself. +import "../util/color"; + +import { + readManifest, + writeManifest, + type TasklessInstallManifest, + type TasklessPermissionRecord, +} from "../filesystem/manifest"; +import { isRecord } from "../util/is-record"; + +/** + * Harness permission rules that allow exactly the installed Taskless CLI. + * + * Installing Taskless at a version is the user choosing to run that version, + * so `init` records the choice in the project permission file of each harness + * it installs the skill for. The CLI writes the rule, not the agent: a harness + * that blocks the CLI also blocks an agent editing its own permissions. + * + * Three properties hold for every harness here: + * + * - The rule is never wider than the invocation. Each uses that harness's + * word-bounded form, so `@0.13.0` cannot match `@0.13.01`. + * - Only rules Taskless recorded in the manifest are ever removed, so a rule + * the user wrote, even one for Taskless, is never touched. + * - A file that denies the invocation, or does not parse, is left alone. + * + * The list is closed. A harness joins by becoming an install target with a + * documented, file-based allowlist that can scope to one command; every other + * harness relies on the skill's per-command approval. + */ + +/** One permission file's outcome, in the shape `init --json` carries. */ +export interface PermissionEdit { + /** Root-relative, `/`-separated path. */ + file: string; + /** Whether this run created the file. */ + created: boolean; + /** Rules added, as written in the file. */ + allowed: string[]; + /** Rules Taskless recorded earlier and removed. */ + removed: string[]; + /** Why the file was left alone, or `null` when it was not. */ + skipped: string | null; +} + +type Records = Record; + +type EditResult = + | { kind: "write"; text: string | undefined; added: string[] } + | { kind: "skip"; reason: string }; + +interface Harness { + /** The file this harness's rule goes in, relative to the project root. */ + locate(cwd: string): Promise; + /** Whether a recorded path belongs to this harness (for cleanup). */ + owns(file: string): boolean; + /** The rules that allow exactly `invocation`. */ + rules(invocation: string): string[]; + /** + * Remove `remove` and add `add` to `text` (absent when the file does not + * exist). `undefined` text in the result deletes the file. + */ + edit( + text: string | undefined, + add: readonly string[], + remove: readonly string[], + context: EditContext + ): EditResult; +} + +interface EditContext { + invocation: string; + /** Whether Taskless created this file, so may delete it once it is empty. */ + created: boolean; +} + +// --- JSON editing ----------------------------------------------------------- + +const UNPARSEABLE = "does not parse, so it was left as it is"; + +/** Parse JSON or JSONC, or `undefined` when it does not parse. */ +function parseJson(text: string): unknown { + const errors: ParseError[] = []; + const value: unknown = parse(text, errors, { allowTrailingComma: true }); + return errors.length > 0 ? undefined : value; +} + +/** The file's own indentation, so an edit does not reformat it. */ +function formattingOf(text: string): FormattingOptions { + const indent = /^([ \t]+)\S/m.exec(text)?.[1]; + if (indent?.startsWith("\t")) { + return { insertSpaces: false, tabSize: 1, eol: "\n" }; + } + return { insertSpaces: true, tabSize: indent?.length ?? 2, eol: "\n" }; +} + +/** The whitespace that opens the line holding `offset`. */ +function indentAt(text: string, offset: number): string { + const start = text.lastIndexOf("\n", offset - 1) + 1; + return /^[ \t]*/.exec(text.slice(start))?.[0] ?? ""; +} + +/** A value on one line: `{ "action": "shell", … }` rather than a block. */ +function inline(value: unknown): string { + return JSON.stringify(value, undefined, 1).replaceAll(/\n\s*/g, " "); +} + +/** Whether only whitespace, or a comment, follows `offset` on its line. */ +function restOfLineIsBlank(text: string, offset: number): boolean { + const lineEnd = text.indexOf("\n", offset); + const rest = text.slice(offset, lineEnd === -1 ? text.length : lineEnd); + return /^\s*(?:(?:\/\/|\/\*).*)?$/.test(rest); +} + +/** + * Place a bare insertion by hand, or `undefined` when it is not one this + * handles. An entry appended after a sibling that ends its line gets a line of + * its own, after any comment there; one inserted before a sibling on a line + * of its own gets a line of its own above it. Anything inline stays inline. + */ +function placeBySibling( + text: string, + edit: { offset: number; length: number; content: string }, + value: unknown, + isArrayInsertion: boolean +): string | undefined { + if (edit.length > 0) return undefined; + const indent = indentAt(text, edit.offset); + if (edit.content.startsWith(",")) { + const entry = edit.content + .slice(1) + .replace(/\{.*\}$/s, () => inline(value)); + if (!restOfLineIsBlank(text, edit.offset)) { + return `${text.slice(0, edit.offset)}, ${entry}${text.slice(edit.offset)}`; + } + const lineEnd = text.indexOf("\n", edit.offset); + const end = lineEnd === -1 ? text.length : lineEnd; + return ( + `${text.slice(0, edit.offset)},${text.slice(edit.offset, end)}` + + `\n${indent}${entry}${text.slice(end)}` + ); + } + if (isArrayInsertion && edit.content.endsWith(",")) { + // The bare edit sits right after `[`; the sibling may start a line below. + const element = + edit.offset + Math.max(0, text.slice(edit.offset).search(/\S/)); + if (text.slice(edit.offset, element).includes("\n")) { + return ( + `${text.slice(0, element)}${inline(value)},\n` + + `${indentAt(text, element)}${text.slice(element)}` + ); + } + return `${text.slice(0, edit.offset)}${inline(value)}, ${text.slice(edit.offset)}`; + } + return undefined; +} + +/** Whether `text` parses and holds `value` where it was written. */ +function holds( + text: string, + path: (string | number)[], + value: unknown, + isArrayInsertion: boolean +): boolean { + const root = parseJson(text); + if (root === undefined) return false; + const keys = path.map(String); + const expected = JSON.stringify(value); + if (isArrayInsertion) { + const list = valueAt(root, keys.slice(0, -1)); + return ( + Array.isArray(list) && + list.some((item) => JSON.stringify(item) === expected) + ); + } + return JSON.stringify(valueAt(root, keys)) === expected; +} + +/** + * Delete the entry at `path`: an array element or an object property, with + * the comma that separated it from its neighbour, and nothing else. + * + * Computed from the parse tree rather than asked of `jsonc-parser`, whose + * removal range came up one character short after an inline insertion and + * left a stray quote behind. + */ +function remove(text: string, path: (string | number)[]): string { + const tree = parseTree(text, [], { allowTrailingComma: true }); + const found = tree === undefined ? undefined : findNodeAtLocation(tree, path); + // A property is deleted whole, key included. + const node = found?.parent?.type === "property" ? found.parent : found; + const siblings = node?.parent?.children; + if (node === undefined || siblings === undefined) return text; + const index = siblings.indexOf(node); + const previous = siblings[index - 1]; + const next = siblings[index + 1]; + const [start, end] = + previous === undefined + ? [node.offset, next?.offset ?? node.offset + node.length] + : [previous.offset + previous.length, node.offset + node.length]; + return text.slice(0, start) + text.slice(end); +} + +/** + * Write `value` at `path`, touching nothing else in the file. + * + * `jsonc-parser` is asked for a bare edit and the new entry is placed by hand + * ({@link placeBySibling}), because its formatter reflows the line it inserts + * beside, comment and all: appending after `"*": "ask" // ask by default` left + * that comment describing the new entry. Removals are bare deletions. The + * formatter is used to create a container that does not exist yet, where + * there is no neighbour to disturb, and as the fallback whenever a hand-placed + * edit would not parse back to the value it was meant to write. + */ +function set( + text: string, + path: (string | number)[], + value: unknown, + options: { isArrayInsertion?: boolean } = {} +): string { + if (value === undefined) return remove(text, path); + const bare = modify(text, path, value, { ...options }); + const [edit] = bare; + if (bare.length !== 1 || edit === undefined) { + return applyEdits(text, bare); + } + const isArrayInsertion = options.isArrayInsertion === true; + const placed = placeBySibling(text, edit, value, isArrayInsertion); + if (placed !== undefined && holds(placed, path, value, isArrayInsertion)) { + return placed; + } + return applyEdits( + text, + modify(text, path, value, { + formattingOptions: formattingOf(text), + ...options, + }) + ); +} + +function valueAt(root: unknown, path: readonly string[]): unknown { + let current = root; + for (const key of path) { + if (!isRecord(current)) return undefined; + current = current[key]; + } + return current; +} + +/** Whether a value holds nothing but empty objects and arrays. */ +function isHollow(value: unknown): boolean { + if (Array.isArray(value)) return value.every((item) => isHollow(item)); + if (isRecord(value)) return Object.values(value).every((v) => isHollow(v)); + return false; +} + +/** + * Finish an edit: a file Taskless created that now holds nothing of anyone's + * is deleted rather than left as an empty skeleton. + */ +function finish( + text: string, + added: string[], + context: EditContext +): EditResult { + if (context.created && isHollow(parseJson(text))) { + return { kind: "write", text: undefined, added }; + } + return { kind: "write", text, added }; +} + +/** + * Edit a list of strings at `path`: remove recorded entries, append the rest. + * An entry already present is not added again, and is not reported as added, + * since it is the user's. + */ +function editStringList( + text: string | undefined, + path: string[], + add: readonly string[], + remove: readonly string[], + context: EditContext, + denies?: (root: unknown) => boolean +): EditResult { + let current = text ?? "{}\n"; + let root = parseJson(current); + if (!isRecord(root)) return { kind: "skip", reason: UNPARSEABLE }; + if (add.length > 0 && denies?.(root)) { + return { kind: "skip", reason: "denies this command" }; + } + const existing = valueAt(root, path); + if (existing !== undefined && !Array.isArray(existing)) { + return { kind: "skip", reason: `${path.join(".")} is not a list` }; + } + + for (const rule of remove) { + const list = valueAt(root, path); + const index = Array.isArray(list) ? list.indexOf(rule) : -1; + if (index === -1) continue; + current = set(current, [...path, index], undefined); + root = parseJson(current); + } + + const added: string[] = []; + for (const rule of add) { + const list = valueAt(root, path); + if (Array.isArray(list) && list.includes(rule)) continue; + current = Array.isArray(list) + ? set(current, [...path, list.length], rule, { isArrayInsertion: true }) + : set(current, path, [rule]); + root = parseJson(current); + added.push(rule); + } + return finish(current, added, context); +} + +// --- Matching a deny -------------------------------------------------------- + +function escapeRegExp(text: string): string { + return text.replaceAll(/[.+?^${}()|[\]\\]/g, String.raw`\$&`); +} + +/** + * A glob as a regular expression: `*` is any run of characters, `?` one, and a + * trailing ` *` also matches nothing at all, the reading every harness here + * gives it. Deliberately generous, since it only decides when to hold back. + */ +function globToRegExp(glob: string): RegExp { + const trailing = glob.endsWith(" *"); + const body = trailing ? glob.slice(0, -2) : glob; + const source = escapeRegExp(body).replaceAll("*", ".*").replaceAll("?", "."); + return new RegExp(`^${source}${trailing ? "( .*)?" : ""}$`, "s"); +} + +/** Commands a deny covering Taskless would match. */ +function sampleCommands(invocation: string): string[] { + return [invocation, `${invocation} agent ok`]; +} + +function coversAny(glob: string, invocation: string): boolean { + const pattern = globToRegExp(glob); + return sampleCommands(invocation).some((command) => pattern.test(command)); +} + +/** Claude Code: `Bash`, `Bash(*)`, `Bash(npx *)`, `Bash(npx:*)`, … */ +function claudeDenies(root: unknown, invocation: string): boolean { + const deny = valueAt(root, ["permissions", "deny"]); + if (!Array.isArray(deny)) return false; + return deny.some((rule) => { + if (typeof rule !== "string") return false; + if (rule === "Bash") return true; + const match = /^Bash\((.*)\)$/s.exec(rule); + if (match?.[1] === undefined) return false; + const glob = match[1].endsWith(":*") + ? `${match[1].slice(0, -2)} *` + : match[1]; + return coversAny(glob, invocation); + }); +} + +/** Cursor CLI: `Shell(base)` or `Shell(base:argsGlob)`. */ +function cursorCliDenies(root: unknown, invocation: string): boolean { + const deny = valueAt(root, ["permissions", "deny"]); + if (!Array.isArray(deny)) return false; + const [base, ...rest] = invocation.split(" "); + return deny.some((rule) => { + if (typeof rule !== "string") return false; + const match = /^Shell\(([^:)]*)(?::(.*))?\)$/s.exec(rule); + if (match === null || match[1] !== base) return false; + if (match[2] === undefined) return true; + const args = globToRegExp(match[2]); + return [rest.join(" "), `${rest.join(" ")} agent ok`].some((a) => + args.test(a) + ); + }); +} + +// --- The harnesses ---------------------------------------------------------- + +const CLAUDE_FILE = ".claude/settings.json"; +const CURSOR_EDITOR_FILE = ".cursor/permissions.json"; +const CURSOR_CLI_FILE = ".cursor/cli.json"; +const CODEX_FILE = ".codex/rules/taskless.rules"; +const OPENCODE_FILES = [ + "opencode.json", + "opencode.jsonc", + ".opencode/opencode.json", + ".opencode/opencode.jsonc", +]; +/** Where an OpenCode rule goes when the project has no config yet. */ +const OPENCODE_DEFAULT = ".opencode/opencode.json"; + +function fixed(file: string): Pick { + return { + locate: () => Promise.resolve(file), + owns: (path) => path === file, + }; +} + +const claudeCode: Harness = { + ...fixed(CLAUDE_FILE), + rules: (invocation) => [`Bash(${invocation} *)`], + edit: (text, add, remove, context) => + editStringList(text, ["permissions", "allow"], add, remove, context, (r) => + claudeDenies(r, context.invocation) + ), +}; + +const cursorEditor: Harness = { + ...fixed(CURSOR_EDITOR_FILE), + rules: (invocation) => [invocation], + edit: (text, add, remove, context) => + editStringList(text, ["terminalAllowlist"], add, remove, context), +}; + +const cursorCli: Harness = { + ...fixed(CURSOR_CLI_FILE), + rules: (invocation) => { + const [base, ...rest] = invocation.split(" "); + const args = rest.join(" "); + return [`Shell(${base ?? ""}:${args})`, `Shell(${base ?? ""}:${args} *)`]; + }, + edit: (text, add, remove, context) => + editStringList(text, ["permissions", "allow"], add, remove, context, (r) => + cursorCliDenies(r, context.invocation) + ), +}; + +/** + * OpenCode, in whichever form the file already uses. + * + * The object form (`permission.bash`) applies the LAST matching rule, so the + * Taskless key is appended after every existing key. The array form + * (`permissions`, `{ action, resource, effect }`) is read top to bottom, so the + * entry goes first. Either way, a deny that our rule would then override is + * the user's decision, and the file is left alone. + */ +const openCode: Harness = { + async locate(cwd) { + for (const file of OPENCODE_FILES) { + if ((await readText(join(cwd, file))) !== undefined) return file; + } + return OPENCODE_DEFAULT; + }, + owns: (path) => OPENCODE_FILES.includes(path), + rules: (invocation) => [`${invocation} *`], + edit(text, add, remove, context) { + let current = text ?? "{}\n"; + let root = parseJson(current); + if (!isRecord(root)) return { kind: "skip", reason: UNPARSEABLE }; + + if (Array.isArray(root.permissions)) { + const isOurs = (entry: unknown, rule: string) => + isRecord(entry) && + entry.action === "shell" && + entry.effect === "allow" && + entry.resource === rule; + if ( + add.length > 0 && + root.permissions.some( + (entry) => + isRecord(entry) && + entry.effect === "deny" && + (entry.action === "shell" || entry.action === "*") && + typeof entry.resource === "string" && + coversAny(entry.resource, context.invocation) + ) + ) { + return { kind: "skip", reason: "denies this command" }; + } + for (const rule of remove) { + const list = valueAt(root, ["permissions"]); + const index = Array.isArray(list) + ? list.findIndex((entry) => isOurs(entry, rule)) + : -1; + if (index === -1) continue; + current = set(current, ["permissions", index], undefined); + root = parseJson(current); + } + const added: string[] = []; + for (const rule of add) { + const list = valueAt(root, ["permissions"]); + if (Array.isArray(list) && list.some((entry) => isOurs(entry, rule))) { + continue; + } + current = set( + current, + ["permissions", 0], + { action: "shell", resource: rule, effect: "allow" }, + { isArrayInsertion: true } + ); + root = parseJson(current); + added.push(rule); + } + return finish(current, added, context); + } + + const permission = root.permission; + if (permission !== undefined && !isRecord(permission)) { + return { + kind: "skip", + reason: "sets `permission` to a single value for every tool", + }; + } + const bash = valueAt(root, ["permission", "bash"]); + if (bash !== undefined && !isRecord(bash)) { + return { + kind: "skip", + reason: "sets `permission.bash` to a single value for every command", + }; + } + if ( + add.length > 0 && + isRecord(bash) && + Object.entries(bash).some( + ([glob, action]) => + action === "deny" && coversAny(glob, context.invocation) + ) + ) { + return { kind: "skip", reason: "denies this command" }; + } + for (const rule of remove) { + const bashRules = valueAt(root, ["permission", "bash"]); + if (!isRecord(bashRules) || bashRules[rule] !== "allow") continue; + current = set(current, ["permission", "bash", rule], undefined); + root = parseJson(current); + } + const added: string[] = []; + for (const rule of add) { + const bashRules = valueAt(root, ["permission", "bash"]); + if (isRecord(bashRules) && rule in bashRules) continue; + current = set(current, ["permission", "bash", rule], "allow"); + root = parseJson(current); + added.push(rule); + } + return finish(current, added, context); + }, +}; + +/** + * Codex, in a rules file Taskless owns outright, so the whole file is + * rewritten rather than edited. A file at that path Taskless did not record is + * someone else's and is left alone. Codex applies the strictest matching rule + * (forbidden over prompt over allow), so a deny elsewhere still wins without + * this file needing to look for it. + */ +const codex: Harness = { + ...fixed(CODEX_FILE), + rules: (invocation) => [ + `prefix_rule(pattern = ${JSON.stringify(invocation.split(" "))}, decision = "allow")`, + ], + edit(text, add, remove, context) { + if (text !== undefined && !context.created && remove.length === 0) { + return { + kind: "skip", + reason: "already exists and was not written by Taskless", + }; + } + const kept = (text ?? "") + .split("\n") + .filter((line) => line.startsWith("prefix_rule(")) + .filter((line) => !remove.includes(line)); + const added = add.filter((rule) => !kept.includes(rule)); + const rules = [...kept, ...added]; + if (rules.length === 0) return { kind: "write", text: undefined, added }; + return { + kind: "write", + text: + "# Written by Taskless: allows exactly the installed Taskless CLI.\n" + + "# `taskless init` rewrites this file on every upgrade.\n" + + rules.join("\n") + + "\n", + added, + }; + }, +}; + +const HARNESSES = [claudeCode, cursorEditor, cursorCli, openCode, codex]; + +/** + * The harnesses that get a rule for this install: one per directory Taskless + * installed the skill into, and Codex only when `.agents/` was selected AND + * Codex is in the project, since `.agents/` is read by many harnesses and + * names none of them. + */ +function harnessesFor( + selected: readonly string[], + codexDetected: boolean +): Harness[] { + const harnesses: Harness[] = []; + if (selected.includes(".claude")) harnesses.push(claudeCode); + if (selected.includes(".cursor")) harnesses.push(cursorEditor, cursorCli); + if (selected.includes(".opencode")) harnesses.push(openCode); + if (selected.includes(".agents") && codexDetected) harnesses.push(codex); + return harnesses; +} + +// --- Sync ------------------------------------------------------------------- + +async function readText(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return undefined; + } +} + +async function readRecords(cwd: string): Promise { + try { + const { manifest } = await readManifest(join(cwd, ".taskless")); + // Typed, but read as unknown: the manifest is a hand-editable file. + const permissions: unknown = manifest.install?.permissions; + if (!isRecord(permissions)) return {}; + const records: Records = {}; + for (const [file, record] of Object.entries(permissions)) { + if (!isRecord(record) || !Array.isArray(record.rules)) continue; + records[file] = { + rules: record.rules.filter((r): r is string => typeof r === "string"), + created: record.created === true, + }; + } + return records; + } catch { + return {}; + } +} + +async function writeRecords(cwd: string, records: Records): Promise { + const directory = join(cwd, ".taskless"); + const { manifest, raw } = await readManifest(directory); + const install: TasklessInstallManifest = { ...manifest.install }; + if (Object.keys(records).length === 0) { + delete install.permissions; + } else { + install.permissions = records; + } + manifest.install = install; + await writeManifest(directory, manifest, raw); +} + +export interface SyncPermissionsOptions { + /** Tool directories the skill is installed into. */ + selected: readonly string[]; + /** Whether Codex is present in the project. */ + codexDetected: boolean; + /** The invocation to allow: this build's pinned CLI. */ + invocation: string; + /** `false` reports what would change and writes nothing. */ + write: boolean; +} + +/** + * Bring every harness permission file in line with this install: add the rule + * for the invocation, remove rules Taskless recorded for any other version or + * for a harness no longer selected, and record what it wrote. + * + * With `write: false` it reads the same files and reports the same edits + * without touching anything, which is what the wizard previews. + */ +export async function syncPermissions( + cwd: string, + options: SyncPermissionsOptions +): Promise { + const previous = await readRecords(cwd); + const desired = new Map(); + for (const harness of harnessesFor(options.selected, options.codexDetected)) { + desired.set(await harness.locate(cwd), { + harness, + rules: harness.rules(options.invocation), + }); + } + + const next: Records = {}; + const edits: PermissionEdit[] = []; + const files = [...new Set([...Object.keys(previous), ...desired.keys()])]; + for (const file of files.toSorted()) { + const harness = + desired.get(file)?.harness ?? HARNESSES.find((h) => h.owns(file)); + const recorded = previous[file]; + if (harness === undefined) { + if (recorded !== undefined) next[file] = recorded; + continue; + } + const want = desired.get(file)?.rules ?? []; + const had = recorded?.rules ?? []; + const remove = had.filter((rule) => !want.includes(rule)); + const add = want.filter((rule) => !had.includes(rule)); + if (add.length === 0 && remove.length === 0) { + if (recorded !== undefined) next[file] = recorded; + continue; + } + + const path = join(cwd, file); + const text = await readText(path); + const created = recorded?.created ?? text === undefined; + const result = harness.edit(text, add, remove, { + invocation: options.invocation, + created, + }); + if (result.kind === "skip") { + if (recorded !== undefined) next[file] = recorded; + edits.push({ + file, + created: false, + allowed: [], + removed: [], + skipped: result.reason, + }); + continue; + } + + const removed = remove; + const rules = [ + ...had.filter((rule) => !remove.includes(rule)), + ...result.added, + ]; + if (rules.length > 0) next[file] = { rules, created }; + if (result.added.length === 0 && removed.length === 0) continue; + edits.push({ + file, + created: text === undefined, + allowed: result.added, + removed, + skipped: null, + }); + + if (!options.write) continue; + if (result.text === undefined) { + await rm(path, { force: true }); + } else { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, result.text, "utf8"); + } + } + + if (options.write) await writeRecords(cwd, next); + return edits; +} + +/** + * The lines that announce each edit. The labels carry the meaning without + * colour, so the output reads the same under `NO_COLOR` or in a log. + */ +export function describePermissionEdits( + edits: readonly PermissionEdit[] +): string[] { + const lines: string[] = []; + for (const edit of edits) { + if (edit.skipped !== null) { + lines.push(chalk.yellow(`SKIPPED: ${edit.file} ${edit.skipped}`)); + continue; + } + lines.push(`${edit.created ? "Created" : "Edited"} ${edit.file}`); + for (const rule of edit.allowed) { + lines.push(` ${chalk.green(`ALLOW: ${rule}`)}`); + } + for (const rule of edit.removed) { + lines.push(` ${chalk.red(`REMOVE: ${rule}`)}`); + } + } + return lines; +} diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index a746c89c..96eb6cbc 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -5,14 +5,17 @@ import { ensureTasklessDirectory } from "../filesystem/directory"; import { applyInstallPlan, buildInstallPlan, + detectTools, getEmbeddedSkills, planToStateTargets, } from "../install/install"; +import { syncPermissions } from "../install/permissions"; import { findStalePins, getPinnedCliNotice } from "../install/pinned-cli"; import { getReloadNotice } from "../install/reload-notice"; import { computeInstallDiff, readInstallState } from "../install/state"; import { getTelemetry } from "../telemetry"; import { CLIError } from "../util/cli-error"; +import { buildInvocation } from "../util/invocation"; import { getCliPrefix } from "../util/package-manager"; import { WizardCancelled } from "./ask"; @@ -58,7 +61,22 @@ export async function runWizard( targets: planToStateTargets(plan), }); - const proceed = await renderSummaryAndConfirm(diff); + // Previewed before anything is written, so every settings file the + // install will touch is on screen before the user confirms. + const detected = await detectTools(options.cwd); + const permissionOptions = { + selected: plan.targets + .filter((target) => target.mode === "reference") + .map((target) => target.dir), + codexDetected: detected.some((tool) => tool.name === "Codex"), + invocation: buildInvocation(), + }; + const permissionPreview = await syncPermissions(options.cwd, { + ...permissionOptions, + write: false, + }); + + const proceed = await renderSummaryAndConfirm(diff, permissionPreview); if (!proceed) { cancel(`Install cancelled. Run \`${getCliPrefix()} init\` to try again.`); cancelledStep = "summary"; @@ -70,6 +88,7 @@ export async function runWizard( }); const cliVersion = getCliVersion(); await applyInstallPlan(options.cwd, plan, { cliVersion }); + await syncPermissions(options.cwd, { ...permissionOptions, write: true }); outro("Taskless is ready to go."); const pinnedNotice = getPinnedCliNotice( diff --git a/packages/cli/src/wizard/steps/summary.ts b/packages/cli/src/wizard/steps/summary.ts index 4d63d242..d1c0fb90 100644 --- a/packages/cli/src/wizard/steps/summary.ts +++ b/packages/cli/src/wizard/steps/summary.ts @@ -6,11 +6,16 @@ import chalk from "chalk"; // whichever caller happened to load `wizard/intro.ts` first. import "../../util/color"; +import { + describePermissionEdits, + type PermissionEdit, +} from "../../install/permissions"; import type { InstallDiff } from "../../install/state"; import { ask } from "../ask"; export async function renderSummaryAndConfirm( - diff: InstallDiff + diff: InstallDiff, + permissions: readonly PermissionEdit[] = [] ): Promise { const lines: string[] = []; @@ -46,11 +51,30 @@ export async function renderSummaryAndConfirm( lines.push(""); } + if (permissions.length > 0) { + lines.push( + chalk.bold("Agent permissions"), + ...describePermissionEdits(permissions), + "" + ); + } + log.message(lines.join("\n") || "No changes."); - if (!diff.hasRemovals) { + // A settings file is the user's, so an edit to one is confirmed even when + // nothing is being removed. + const editsSettings = permissions.some((edit) => edit.skipped === null); + if (!diff.hasRemovals && !editsSettings) { return true; } + if (!diff.hasRemovals) { + return ask("summary", () => + confirm({ + message: "Write the agent permission rules above?", + initialValue: true, + }) + ); + } return ask("summary", () => confirm({ diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index 9f0ba33a..d7d00f31 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -187,7 +187,10 @@ describe("this repository's own installed Taskless docs", () => { // user a command that made the change, and that worked. expect(prose).toContain("give the user the change to make themselves"); expect(prose).toContain("`/permissions`"); - expect(prose).toContain("`Bash(%(TASKLESS_CLI)s:*)`"); + expect(prose).toContain("`Bash(%(TASKLESS_CLI)s *)`"); + // A harness like Crush can only allow all of `bash`, far wider than one + // invocation. + expect(prose).toContain("offer only per-command approval"); expect(prose).toContain( "approve every Taskless command manually, here in chat" ); diff --git a/packages/cli/test/migrated-envelope.test.ts b/packages/cli/test/migrated-envelope.test.ts index c766e4c9..0e56d0a8 100644 --- a/packages/cli/test/migrated-envelope.test.ts +++ b/packages/cli/test/migrated-envelope.test.ts @@ -173,6 +173,13 @@ describe("who migrates, and who refuses", () => { }>; changed: boolean; migrated?: unknown; + permissions: Array<{ + file: string; + created: boolean; + allowed: string[]; + removed: string[]; + skipped: string | null; + }>; }; // A seeded project has no recorded install, and "none" is a value. @@ -187,6 +194,17 @@ describe("who migrates, and who refuses", () => { expect(envelope.migrated).toBeDefined(); expect(envelope.changed).toBe(true); + + // The settings edit is data on the envelope, not only prose on stderr. + expect(envelope.permissions).toEqual([ + { + file: ".claude/settings.json", + created: true, + allowed: [`Bash(npx @taskless/cli@${envelope.cliVersion.installed} *)`], + removed: [], + skipped: null, + }, + ]); }); it("init --json reports changed:false, a recorded version, and empty lists on a no-op re-install", async () => { diff --git a/packages/cli/test/permissions.test.ts b/packages/cli/test/permissions.test.ts new file mode 100644 index 00000000..57fe489b --- /dev/null +++ b/packages/cli/test/permissions.test.ts @@ -0,0 +1,370 @@ +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import chalk from "chalk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + describePermissionEdits, + syncPermissions, + type SyncPermissionsOptions, +} from "../src/install/permissions"; + +const V1 = "npx @taskless/cli@0.13.0"; +const V2 = "npx @taskless/cli@0.13.1"; + +let cwd: string; + +beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-permissions-")); + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ version: 2, install: {} }) + ); +}); + +afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); +}); + +async function put(path: string, contents: string): Promise { + await mkdir(dirname(join(cwd, path)), { recursive: true }); + await writeFile(join(cwd, path), contents); +} + +async function text(path: string): Promise { + return readFile(join(cwd, path), "utf8"); +} + +async function exists(path: string): Promise { + try { + await stat(join(cwd, path)); + return true; + } catch { + return false; + } +} + +async function json(path: string): Promise { + return JSON.parse(await text(path)) as unknown; +} + +function sync( + options: Partial & { selected: string[] } +): ReturnType { + return syncPermissions(cwd, { + codexDetected: false, + invocation: V1, + write: true, + ...options, + }); +} + +describe("Claude Code", () => { + it("adds a word-bounded rule and keeps everything else", async () => { + await put( + ".claude/settings.json", + '{\n "permissions": {\n "allow": ["Bash(git status)"]\n },\n "model": "opus"\n}\n' + ); + const edits = await sync({ selected: [".claude"] }); + + expect(edits).toEqual([ + { + file: ".claude/settings.json", + created: false, + allowed: [`Bash(${V1} *)`], + removed: [], + skipped: null, + }, + ]); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: ["Bash(git status)", `Bash(${V1} *)`] }, + model: "opus", + }); + }); + + // The space before `*` is the word boundary: `@0.13.0 *` requires a space + // after the version, so `@0.13.01` does not match. + it("never writes a rule that could match a longer version", async () => { + await sync({ selected: [".claude"] }); + const [rule] = ( + (await json(".claude/settings.json")) as { + permissions: { allow: string[] }; + } + ).permissions.allow; + expect(rule).toBe(`Bash(${V1} *)`); + expect(rule).not.toMatch(/@0\.13\.0\*/); + }); + + it("leaves a file that denies the command alone", async () => { + const original = '{ "permissions": { "deny": ["Bash(npx *)"] } }\n'; + await put(".claude/settings.json", original); + const edits = await sync({ selected: [".claude"] }); + + expect(edits[0]?.skipped).toBe("denies this command"); + expect(await text(".claude/settings.json")).toBe(original); + }); + + it("leaves a file that does not parse alone", async () => { + await put(".claude/settings.json", "{ not json"); + const edits = await sync({ selected: [".claude"] }); + + expect(edits[0]?.skipped).toMatch(/does not parse/); + expect(await text(".claude/settings.json")).toBe("{ not json"); + }); +}); + +describe("upgrades", () => { + it("swaps the recorded rule for the new version", async () => { + await sync({ selected: [".claude"], invocation: V1 }); + const edits = await sync({ selected: [".claude"], invocation: V2 }); + + expect(edits[0]).toMatchObject({ + allowed: [`Bash(${V2} *)`], + removed: [`Bash(${V1} *)`], + }); + // Created by Taskless and holding only its rule, so it stays as a file + // with the new rule rather than being deleted mid-swap. + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${V2} *)`] }, + }); + const manifest = (await json(".taskless/taskless.json")) as { + install: { permissions: Record }; + }; + expect( + manifest.install.permissions[".claude/settings.json"]?.rules + ).toEqual([`Bash(${V2} *)`]); + }); + + it("never removes a rule Taskless did not record", async () => { + // The user allowed an older Taskless themselves. + await put( + ".claude/settings.json", + JSON.stringify({ + permissions: { allow: ["Bash(npx @taskless/cli@0.12.0 *)"] }, + }) + ); + await sync({ selected: [".claude"], invocation: V1 }); + await sync({ selected: [".claude"], invocation: V2 }); + + expect(await json(".claude/settings.json")).toEqual({ + permissions: { + allow: ["Bash(npx @taskless/cli@0.12.0 *)", `Bash(${V2} *)`], + }, + }); + }); + + it("removes only its own line from a multi-line list", async () => { + await put( + ".claude/settings.json", + '{\n "permissions": {\n "allow": [\n "Bash(git status)", // mine\n "Bash(ls *)"\n ]\n }\n}\n' + ); + await sync({ selected: [".claude"], invocation: V1 }); + await sync({ selected: [".claude"], invocation: V2 }); + + expect(await text(".claude/settings.json")).toBe( + `{\n "permissions": {\n "allow": [\n "Bash(git status)", // mine\n "Bash(ls *)",\n "Bash(${V2} *)"\n ]\n }\n}\n` + ); + }); + + it("deletes a file Taskless created once it holds nothing else", async () => { + await sync({ selected: [".cursor"] }); + expect(await exists(".cursor/permissions.json")).toBe(true); + + const edits = await sync({ selected: [] }); + + expect(edits.map((edit) => edit.removed.length > 0)).toEqual([true, true]); + expect(await exists(".cursor/permissions.json")).toBe(false); + expect(await exists(".cursor/cli.json")).toBe(false); + }); + + it("never deletes a file that existed before", async () => { + await put(".claude/settings.json", "{}\n"); + await sync({ selected: [".claude"] }); + await sync({ selected: [] }); + + expect(await exists(".claude/settings.json")).toBe(true); + }); +}); + +describe("Cursor", () => { + it("writes the editor allowlist and the CLI rules", async () => { + await sync({ selected: [".cursor"] }); + + expect(await json(".cursor/permissions.json")).toEqual({ + terminalAllowlist: [V1], + }); + expect(await json(".cursor/cli.json")).toEqual({ + permissions: { + allow: [ + "Shell(npx:@taskless/cli@0.13.0)", + "Shell(npx:@taskless/cli@0.13.0 *)", + ], + }, + }); + }); + + it("respects a CLI deny on the command base", async () => { + const original = '{ "permissions": { "deny": ["Shell(npx)"] } }\n'; + await put(".cursor/cli.json", original); + const edits = await sync({ selected: [".cursor"] }); + + expect( + edits.find((edit) => edit.file === ".cursor/cli.json")?.skipped + ).toBe("denies this command"); + expect(await text(".cursor/cli.json")).toBe(original); + }); +}); + +describe("OpenCode", () => { + // The object form applies the LAST matching rule. + it("puts the rule after a catch-all in the object form, keeping comments", async () => { + await put( + ".opencode/opencode.jsonc", + '{\n // mine\n "permission": {\n "bash": {\n "*": "ask" // ask by default\n }\n }\n}\n' + ); + await sync({ selected: [".opencode"] }); + + expect(await text(".opencode/opencode.jsonc")).toBe( + `{\n // mine\n "permission": {\n "bash": {\n "*": "ask", // ask by default\n "${V1} *": "allow"\n }\n }\n}\n` + ); + }); + + // The array form is read top to bottom. + it("puts the rule first in the array form", async () => { + await put( + "opencode.json", + '{\n "permissions": [\n { "action": "shell", "resource": "rm *", "effect": "ask" }\n ]\n}\n' + ); + await sync({ selected: [".opencode"] }); + + expect(await json("opencode.json")).toEqual({ + permissions: [ + { action: "shell", resource: `${V1} *`, effect: "allow" }, + { action: "shell", resource: "rm *", effect: "ask" }, + ], + }); + }); + + // Appended last, our allow would override this deny, so it is not written. + it("leaves an object-form deny that covers the command alone", async () => { + const original = '{ "permission": { "bash": { "npx *": "deny" } } }\n'; + await put("opencode.json", original); + const edits = await sync({ selected: [".opencode"] }); + + expect(edits[0]?.skipped).toBe("denies this command"); + expect(await text("opencode.json")).toBe(original); + }); + + it("creates .opencode/opencode.json when the project has none", async () => { + await sync({ selected: [".opencode"] }); + + expect(await json(".opencode/opencode.json")).toEqual({ + permission: { bash: { [`${V1} *`]: "allow" } }, + }); + }); +}); + +describe("Codex", () => { + it("writes its own rules file when Codex is detected", async () => { + await sync({ selected: [".agents"], codexDetected: true }); + + expect(await text(".codex/rules/taskless.rules")).toContain( + 'prefix_rule(pattern = ["npx","@taskless/cli@0.13.0"], decision = "allow")' + ); + }); + + it("writes nothing for .agents/ without Codex", async () => { + expect(await sync({ selected: [".agents"] })).toEqual([]); + expect(await exists(".codex")).toBe(false); + }); + + it("leaves a rules file at its path that Taskless did not write", async () => { + await put(".codex/rules/taskless.rules", "# theirs\n"); + const edits = await sync({ selected: [".agents"], codexDetected: true }); + + expect(edits[0]?.skipped).toMatch(/not written by Taskless/); + expect(await text(".codex/rules/taskless.rules")).toBe("# theirs\n"); + }); +}); + +describe("scope", () => { + it("writes nothing for a harness that is not an install target", async () => { + await mkdir(join(cwd, ".devin"), { recursive: true }); + expect(await sync({ selected: [".agents"] })).toEqual([]); + expect(await exists(".devin/config.json")).toBe(false); + }); + + it("reports without writing in a dry run", async () => { + const edits = await sync({ selected: [".claude"], write: false }); + + expect(edits[0]?.allowed).toEqual([`Bash(${V1} *)`]); + expect(await exists(".claude/settings.json")).toBe(false); + const manifest = (await json(".taskless/taskless.json")) as { + install: Record; + }; + expect(manifest.install.permissions).toBeUndefined(); + }); +}); + +describe("describePermissionEdits", () => { + it("labels every edit so it reads without colour", () => { + const level = chalk.level; + chalk.level = 0; + try { + expect( + describePermissionEdits([ + { + file: ".claude/settings.json", + created: false, + allowed: [`Bash(${V2} *)`], + removed: [`Bash(${V1} *)`], + skipped: null, + }, + { + file: ".cursor/cli.json", + created: false, + allowed: [], + removed: [], + skipped: "denies this command", + }, + ]) + ).toEqual([ + "Edited .claude/settings.json", + ` ALLOW: Bash(${V2} *)`, + ` REMOVE: Bash(${V1} *)`, + "SKIPPED: .cursor/cli.json denies this command", + ]); + } finally { + chalk.level = level; + } + }); + + it("colours ALLOW green", () => { + const level = chalk.level; + chalk.level = 1; + try { + const [, allow] = describePermissionEdits([ + { + file: ".claude/settings.json", + created: true, + allowed: [`Bash(${V1} *)`], + removed: [], + skipped: null, + }, + ]); + expect(allow).toBe(` ${chalk.green(`ALLOW: Bash(${V1} *)`)}`); + expect(allow).toContain("\u001B[32m"); + } finally { + chalk.level = level; + } + }); +}); diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index b1858ba9..7c6f64e9 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -32,6 +32,9 @@ const clackResponses: { // Captures the message of the summary confirm prompt for assertions. let summaryConfirmMessage: string | undefined; +// Everything the wizard showed through `log.message`, in order. +const loggedMessages: string[] = []; + vi.mock("@clack/prompts", () => ({ intro: () => {}, outro: () => {}, @@ -40,7 +43,9 @@ vi.mock("@clack/prompts", () => ({ }, log: { info: () => {}, - message: () => {}, + message: (message: string) => { + loggedMessages.push(message); + }, error: () => {}, warn: () => {}, }, @@ -72,6 +77,7 @@ beforeEach(async () => { clackResponses.locations = undefined; clackResponses.summary = undefined; summaryConfirmMessage = undefined; + loggedMessages.length = 0; vi.stubEnv("TASKLESS_TOKEN", "stub-token"); }); @@ -165,6 +171,40 @@ describe("runWizard end-to-end", () => { expect(events).not.toContain("cli_installed"); }); + it("shows the permission edit before writing, and asks", async () => { + clackResponses.locations = [".claude"]; + clackResponses.summary = false; + + const { runWizard } = await import("../src/wizard"); + const result = await runWizard({ cwd }); + + // A fresh install removes nothing, so the confirm is there only because + // a settings file would be edited. + expect(summaryConfirmMessage).toBe( + "Write the agent permission rules above?" + ); + const summary = loggedMessages.join("\n"); + expect(summary).toContain(".claude/settings.json"); + expect(summary).toMatch(/ALLOW: Bash\(npx @taskless\/cli@\S+ \*\)/); + expect(result.status).toBe("cancelled"); + expect(await exists(join(cwd, ".claude", "settings.json"))).toBe(false); + }); + + it("writes the permission rule once confirmed", async () => { + clackResponses.locations = [".claude"]; + clackResponses.summary = true; + + const { runWizard } = await import("../src/wizard"); + await runWizard({ cwd }); + + const settings = JSON.parse( + await readFile(join(cwd, ".claude", "settings.json"), "utf8") + ) as { permissions: { allow: string[] } }; + expect(settings.permissions.allow).toEqual([ + expect.stringMatching(/^Bash\(npx @taskless\/cli@\S+ \*\)$/), + ]); + }); + it("cancelling the summary confirm writes nothing", async () => { clackResponses.locations = [".claude"]; clackResponses.summary = false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93718444..db7cabec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: jose: specifier: ^6.2.2 version: 6.2.2 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 openapi-fetch: specifier: ^0.17.0 version: 0.17.0 diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index c4b79c06..f2d54d7c 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -39,6 +39,8 @@ Once per session, before any other Taskless command, run `%(TASKLESS_CLI)s agent ok`. It touches no project files, and prints one line confirming the invocation works. Once you see it, go straight to the user's request: no survey of the install, and no menu of what Taskless can do. +`init` writes an allow rule for this exact invocation into the permission +settings of each harness it installs for, so the preflight usually passes. If the harness or the user blocks it, or blocks any later Taskless command, stop before doing any Taskless work and do not try to get around the block. @@ -49,12 +51,13 @@ runs a package from npm, and offers three ways forward: agent's permission settings. If the harness blocks that edit too, give the user the change to make themselves, in whatever form your harness accepts. - They approve it through their agent's permissions command. In Claude Code - that is `/permissions`, and the rule is `Bash(%(TASKLESS_CLI)s:*)`. + that is `/permissions`, and the rule is `Bash(%(TASKLESS_CLI)s *)`. - They approve every Taskless command manually, here in chat, as you run it. Never propose a rule wider than that invocation, such as a wildcard over the package name or its version: it would approve a CLI this project never -installed. +installed. If your harness can only allow a whole tool, such as every shell +command, offer only per-command approval. Do not ask what they want Taskless to do in that message. Their request stands: once they unblock the command, rerun `agent ok` and carry it out.