Skip to content

feat(plugins): add CLI enable/disable for plugin.json - #693

Open
Ayush7614 wants to merge 5 commits into
Gitlawb:mainfrom
Ayush7614:feat/plugins-enable-disable
Open

feat(plugins): add CLI enable/disable for plugin.json#693
Ayush7614 wants to merge 5 commits into
Gitlawb:mainfrom
Ayush7614:feat/plugins-enable-disable

Conversation

@Ayush7614

@Ayush7614 Ayush7614 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add zero plugins enable|disable <id> to toggle the enabled field in plugin.json (with --user / --json)
  • Add plugins.SetEnabled / SetEnabledByID helpers used by the CLI
  • Update docs/EXTENDING.md so the documented gap matches the new commands

Closes #790

Test plan

  • go test ./internal/plugins/ ./internal/cli/ -run 'Enabled|EnableDisable|PluginInfo|Info|SetEnabled' -count=1
  • go vet ./internal/plugins/ ./internal/cli/
  • govulncheck ./internal/plugins/ — no vulnerabilities
  • Windows: TestSetEnabledPreservesManifestMode skipped on windows (dae3ef8)
  • Merged latest main (includes #773 plugins info) and deduplicated ErrNotInstalled

Summary by CodeRabbit

  • New Features

    • Added zero plugins enable <id> and zero plugins disable <id> commands (without removing the plugin).
    • Added --user to target only user-installed plugin manifests, ignoring project plugins.
    • Added --json output and clearer messaging for “already enabled/disabled” vs “now enabled/disabled”.
    • Updated command help to include the new subcommands.
  • Bug Fixes

    • Preserves existing plugin.json permission bits when toggling enablement.
  • Documentation

    • Updated plugin docs with the new enable/disable commands and activation rules based on plugin.json.enabled (defaults to enabled when omitted).

Add zero plugins enable|disable so installed plugins can be toggled
without removing them, matching hooks/MCP parity and EXTENDING docs.
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds zero plugins enable and disable commands backed by manifest updates. Plugin discovery supports project precedence and user-only filtering, with JSON/text output, help text, persistence tests, and updated activation documentation.

Changes

Plugin enablement controls

Layer / File(s) Summary
Manifest state management
internal/plugins/enabled.go, internal/plugins/enabled_test.go, internal/plugins/info.go
Adds plugin discovery, manifest enabled updates, idempotent results, source filtering, atomic persistence, permission preservation, and filesystem-backed tests.
CLI commands and output
internal/cli/extensions.go, internal/cli/extensions_test.go
Adds enable and disable routing, flag parsing, help output, JSON/text responses, error handling, and CLI coverage.
Plugin activation documentation
docs/EXTENDING.md
Documents the new commands, default activation semantics, and --user behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant zero plugins
  participant plugins.SetEnabledByID
  participant plugin.json
  User->>zero plugins: enable or disable plugin id
  zero plugins->>plugins.SetEnabledByID: resolve plugin and requested state
  plugins.SetEnabledByID->>plugin.json: update enabled field
  plugin.json-->>zero plugins: return toggle result
  zero plugins-->>User: display text or redacted JSON status
Loading

Possibly related issues

Possibly related PRs

  • Gitlawb/zero#61: Provides the plugin loading and CLI infrastructure extended by these enable/disable commands.
  • Gitlawb/zero#653: Extends the same plugins CLI dispatcher with related enable/disable command routing.

Suggested reviewers: gnanam1990, vasanthdev2004

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding plugin enable/disable CLI support that toggles plugin.json.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/cli/extensions_test.go (1)

338-395: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Solid happy-path/usage-error coverage; consider adding a --user and no-op-message case.

Coverage for --user targeting and the "was already %s" no-op branch currently only exists at the plugins package level (enabled_test.go), not at the CLI layer. A quick additional case exercising zero plugins disable <id> --user against dual user/project roots, and re-running disable on an already-disabled plugin to check the "was already disabled" message, would close that gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/extensions_test.go` around lines 338 - 395, The
TestRunPluginsEnableDisable CLI test only covers project-scoped changes and the
initial disable message. Extend it with separate user and project plugin roots,
invoke the disable command with --user to verify the user manifest is updated
without changing the project manifest, then repeat disable on the
already-disabled plugin and assert the “was already disabled” no-op message.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/plugins/enabled.go`:
- Around line 89-110: Introduce or reuse a sentinel error for the missing-plugin
case in SetEnabledByID and wrap it with the plugin ID context. In
internal/plugins/enabled.go:89-110, update the !ok return; in
internal/cli/extensions.go:122-128, replace strings.Contains(err.Error(), "is
not installed") with errors.Is against that sentinel, adding the required
import. Preserve distinct handling for the empty-ID validation error.

---

Nitpick comments:
In `@internal/cli/extensions_test.go`:
- Around line 338-395: The TestRunPluginsEnableDisable CLI test only covers
project-scoped changes and the initial disable message. Extend it with separate
user and project plugin roots, invoke the disable command with --user to verify
the user manifest is updated without changing the project manifest, then repeat
disable on the already-disabled plugin and assert the “was already disabled”
no-op message.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d03e2bd7-3ab1-4c50-914f-11de8cd3057f

📥 Commits

Reviewing files that changed from the base of the PR and between 6fc1220 and 3d70c0c.

📒 Files selected for processing (5)
  • docs/EXTENDING.md
  • internal/cli/extensions.go
  • internal/cli/extensions_test.go
  • internal/plugins/enabled.go
  • internal/plugins/enabled_test.go

Comment on lines +89 to +110
// SetEnabledByID finds a plugin by id (respecting LoadOptions) and toggles its
// manifest enabled field.
func SetEnabledByID(options LoadOptions, id string, enabled bool) (SetEnabledResult, error) {
plugin, ok, err := FindByID(options, id)
if err != nil {
return SetEnabledResult{}, err
}
if !ok {
return SetEnabledResult{}, fmt.Errorf("plugin %q is not installed", id)
}
changed, err := SetEnabled(plugin.ManifestPath, enabled)
if err != nil {
return SetEnabledResult{}, err
}
return SetEnabledResult{
ID: plugin.ID,
Enabled: enabled,
Changed: changed,
Source: plugin.Source,
ManifestPath: plugin.ManifestPath,
}, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant files and nearby code paths.
sed -n '1,220p' internal/plugins/enabled.go
printf '\n--- extensions.go ---\n'
sed -n '1,220p' internal/cli/extensions.go

Repository: Gitlawb/zero

Length of output: 9719


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for the error strings and any sentinel/type already used elsewhere.
rg -n '"is not installed"|plugin id is required|ErrPlugin|errors.Is|Contains\(err.Error' internal -S

Repository: Gitlawb/zero

Length of output: 47876


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find all callers and tests around plugin enable/disable so we can judge reachability.
rg -n 'SetEnabledByID\(|runPluginsToggle\(|plugins enable|plugins disable|plugin id is required|is not installed' internal -S

Repository: Gitlawb/zero

Length of output: 1703


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the toggle parser and any tests that cover empty or missing IDs.
sed -n '100,170p' internal/cli/extensions.go
printf '\n--- cli tests ---\n'
rg -n 'plugins (enable|disable)|is not installed|plugin id is required|usage: zero plugins (enable|disable)' internal/cli internal/plugins -S

Repository: Gitlawb/zero

Length of output: 2710


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the plugin enable tests and the install code for matching error-handling patterns.
sed -n '1,220p' internal/plugins/enabled_test.go
printf '\n--- install.go excerpt ---\n'
sed -n '160,230p' internal/plugins/install.go

Repository: Gitlawb/zero

Length of output: 5170


Use a typed error for the missing-plugin path. SetEnabledByID should wrap plugin %q is not installed with a sentinel, and internal/cli/extensions.go should switch from strings.Contains(err.Error(), "is not installed") to errors.Is(...); otherwise the empty-id path (plugin id is required) still falls into the crash branch.

📍 Affects 2 files
  • internal/plugins/enabled.go#L89-L110 (this comment)
  • internal/cli/extensions.go#L122-L128
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/plugins/enabled.go` around lines 89 - 110, Introduce or reuse a
sentinel error for the missing-plugin case in SetEnabledByID and wrap it with
the plugin ID context. In internal/plugins/enabled.go:89-110, update the !ok
return; in internal/cli/extensions.go:122-128, replace
strings.Contains(err.Error(), "is not installed") with errors.Is against that
sentinel, adding the required import. Preserve distinct handling for the
empty-ID validation error.

Replace string matching with errors.Is for missing plugins, and extend
CLI tests for --user targeting and the already-disabled no-op message.
@Ayush7614

Ayush7614 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review the latest commits and changes

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@Ayush7614 I see you've tagged me but there's no specific request in your comment — could you clarify what you'd like me to do? For example, I can:

  • Review the PR changes (internal/cli/extensions.go, internal/plugins/enabled.go, and their tests)
  • Answer a question about the plugin enable/disable implementation
  • Help with something else specific

Let me know and I'll jump right in.

(ノ・ヮ・)ノ

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd . -e go internal/plugins internal/cli | xargs echo
echo "---enabled.go---"
cat -n internal/plugins/enabled.go
echo "---extensions.go (relevant portion)---"
cat -n internal/cli/extensions.go | sed -n '1,400p'

Length of output: 23976


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "---activate.go (look for similar manifest write patterns)---"
rg -n "WriteFile|os.Rename|Chmod|optionalBool" internal/plugins/*.go
echo "---optionalBool def---"
rg -n "func optionalBool" -A 20 internal/plugins/*.go

Length of output: 3794


@Ayush7614 Reviewed the latest commits. Overall the implementation is solid — errors.Is-based error handling, atomic manifest writes (temp file + rename in the same directory), and the --user/--json flag plumbing all look correct, and the new test coverage (TestSetEnabledTogglesManifestAndIsIdempotent, TestSetEnabledByIDRespectsUserOnlyFilter, TestSetEnabledByIDMissingPlugin, TestRunPluginsEnableDisable, TestRunPluginsDisableUserIgnoresProject) exercises the important paths (idempotency, project-vs-user precedence, missing plugin, JSON output).

One functional concern worth fixing before merge:

File permission regression in internal/plugins/enabled.go (SetEnabled, Line 83)

if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {

This hardcodes 0o600 for the rewritten plugin.json, but every other manifest write in this codebase (e.g., internal/plugins/install.go Line 242, and test fixtures) uses 0o644. Since SetEnabled replaces the manifest via os.Rename, the first enable/disable toggle will silently downgrade a project-tracked plugin.json from 0o644 to 0o600 — which could break group/CI read access or diverge from the mode a user/tool originally set, and it's inconsistent with the rest of the plugin file-writing code.

Suggested fix: preserve the original file's mode instead of hardcoding it, e.g.:

mode := os.FileMode(0o644)
if info, statErr := os.Stat(resolved); statErr == nil {
    mode = info.Mode().Perm()
}
if err := os.WriteFile(tempPath, append(data, '\n'), mode); err != nil {
    return false, err
}

Everything else — the ErrNotInstalled sentinel/wrapping, FindByID/SetEnabledByID composition, CLI flag parsing, help text, and JSON/text output branching on result.Changed — looks correct and consistent with the rest of the CLI.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Keep the original manifest permissions when rewriting enabled, falling
back to 0o644 to match install and avoid silently tightening to 0o600.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice feature — the atomic manifest rewrite, idempotency, and project/user precedence all look right, and the new CLI tests pass on my end. One blocker: TestSetEnabledPreservesManifestMode asserts a 0644 mode after os.Chmod, but on Windows os.Stat reports 0666 regardless of chmod, so it fails on windows-latest (which is in the CI matrix and runs go test ./...). Guard that test with a runtime.GOOS != "windows" skip (or drop the exact-bits assertion) and I think this is good to go.

Windows reports 0666 from os.Stat regardless of chmod, so skip the
exact permission assertion on windows-latest CI.
@Ayush7614

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @Vasanthdev2004!

Addressed the Windows CI blocker in dae3ef8: TestSetEnabledPreservesManifestMode now skips on windows-latest since os.Stat reports 0666 regardless of chmod there.

The mode-preservation behavior is still covered on Unix via the existing assertion. Please take another look when you have a moment.

Resolve EXTENDING.md docs conflict to keep both info and enable/disable
examples. Deduplicate ErrNotInstalled so enable/disable and info share
one sentinel after Gitlawb#773.
@Ayush7614

Ayush7614 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@Vasanthdev2004 Merged latest main in e29fddc and cleared the conflict with #773 (zero plugins info):

  • docs/EXTENDING.md now documents info, disable, and enable
  • Shared a single plugins.ErrNotInstalled sentinel used by both info and enable/disable

Earlier review items from this thread:

  • CodeRabbit: ErrNotInstalled + --user / already-disabled CLI coverage — done in 7a1a5b5
  • @Vasanthdev2004: Windows mode assertion skip — done in dae3ef8 (windows-latest CI was green on that commit)

Also opened #790 and linked it via Closes #790. Please add issue-approved when the scope looks good, and re-review if you’re happy with the Windows fix + merge.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/cli/extensions.go (1)

143-147: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Redact the text output before printing the manifest path.

result.ManifestPath is emitted directly here, while the existing list and JSON paths go through redaction. In CI logs this can expose local usernames or home-directory paths. Redact the formatted message before writing it, and add a text-mode test covering the path.

Proposed fix
+	message := ""
 	if result.Changed {
-		if _, err := fmt.Fprintf(stdout, "Plugin %s is now %s in %s.\n", result.ID, state, result.ManifestPath); err != nil {
-			return exitCrash
-		}
-	} else if _, err := fmt.Fprintf(stdout, "Plugin %s was already %s in %s.\n", result.ID, state, result.ManifestPath); err != nil {
+		message = fmt.Sprintf("Plugin %s is now %s in %s.", result.ID, state, result.ManifestPath)
+	} else {
+		message = fmt.Sprintf("Plugin %s was already %s in %s.", result.ID, state, result.ManifestPath)
+	}
+	if _, err := fmt.Fprintln(stdout, redaction.RedactString(message, redaction.Options{})); err != nil {
 		return exitCrash
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/extensions.go` around lines 143 - 147, Update the text output
branches in the extension command around result.Changed to redact the fully
formatted message, including result.ManifestPath, before writing it to stdout.
Reuse the existing redaction mechanism used by the list and JSON paths, and add
a text-mode test verifying sensitive path components are removed from both
changed and already-state output.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/cli/extensions.go`:
- Around line 143-147: Update the text output branches in the extension command
around result.Changed to redact the fully formatted message, including
result.ManifestPath, before writing it to stdout. Reuse the existing redaction
mechanism used by the list and JSON paths, and add a text-mode test verifying
sensitive path components are removed from both changed and already-state
output.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 89d5d6f3-dd8b-4c5e-aff8-5dd55af24541

📥 Commits

Reviewing files that changed from the base of the PR and between dae3ef8 and e29fddc.

📒 Files selected for processing (4)
  • docs/EXTENDING.md
  • internal/cli/extensions.go
  • internal/plugins/enabled.go
  • internal/plugins/info.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/EXTENDING.md
  • internal/plugins/enabled.go

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The key behaviour is right: disabling a plugin actually stops its components loading rather than just hiding it from a listing, which was the thing worth verifying. The state file is written atomically and a missing or corrupt one degrades safely, and the Windows test-mode fix that was the last blocker is in and that leg is green.

A few minor notes for later rather than now: concurrent toggles from two processes are not serialized (last writer wins, which is fine for a CLI toggle but worth knowing), and disabling a plugin mid-session takes effect on the next activation rather than immediately. Neither is a correctness problem for how this is used.

Verification note: the go toolchain is blocked on my machine by Smart App Control, so this is a source review; CI is green.

Thanks for the quick turnaround on the Windows leg. LGTM.

@Ayush7614

Copy link
Copy Markdown
Contributor Author

cc: @kevincodex1

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

Changes requested. The command surface is well built and the feature is wanted, but the manifest write in internal/plugins/enabled.go re-implements — less safely — machinery this same package already owns and already uses.

Reviewed at e29fddc, base d9b882e (origin/main at 097c265). Parent issue #790 carries issue-approved.

What I verified

  • The tests are load-bearing. I stubbed out the publish step so SetEnabled returns changed=true without ever writing the manifest, and two tests caught it with the un-toggled file printed in the failure:

    --- FAIL: TestRunPluginsEnableDisable
        extensions_test.go:376: manifest not disabled: { "schemaVersion": 1, "id": "zero.demo", ... }
    --- FAIL: TestRunPluginsDisableUserIgnoresProject
        extensions_test.go:447: user manifest not disabled: { ..., "enabled": true }
    
  • go vet ./internal/plugins/ ./internal/cli/ clean; go test ./internal/plugins/ -count=1 passes.

  • Merged origin/main into the branch: auto-merge clean, go build ./... succeeds on the merged tree.

  • Confirmed the orphan-temp-file concern does not apply: discovery joins the exact filename at internal/plugins/plugins.go:253 (filepath.Join(pluginDir, "plugin.json")), so a stranded plugin.json.tmp-* is never mistaken for a manifest.

  • Ran on darwin/arm64 from a non-/tmp checkout.

Blocking

  1. SetEnabled takes no lock, while every other mutator in this package does.internal/plugins/enabled.go:41

    Install acquires installtxn.Lock(dir) at internal/plugins/install.go:151, and Remove acquires it at :203. Both then mutate under it. SetEnabled performs a read-modify-write on a file inside that same tree with no lock at all, and installtxn.WriteFileAtomically explicitly documents that responsibility as the caller's:

    // WriteFileAtomically publishes data by renaming a complete sibling temporary
    // file over path. The caller is responsible for any surrounding transaction
    // lock.

    installtxn.Lock is a real cross-process lock — flock on Unix (lock_unix.go), LockFileEx on Windows (lock_windows.go) — which matters here because this repo runs CLI, TUI and daemon against the same state. The concrete failure: zero plugins disable X reads the manifest, zero plugins remove X then takes the lock and tears the directory down, and the first process's rename recreates plugin.json inside the removed plugin's directory. The result is a manifest that discovery will load with no corresponding entry in plugins.lock — precisely the inconsistency the lock exists to prevent.

  2. The atomic write is hand-rolled and is weaker than the helper this package already calls.internal/plugins/enabled.go:78-88

    internal/plugins/install.go:271 already calls installtxn.WriteFileAtomically. The new code writes its own temp-file-plus-os.Rename instead, and differs in two ways that matter:

    • No Sync. installtxn.WriteFileAtomically calls temp.Sync() before closing. Without it, a crash between the write and the rename can publish a truncated or zero-length plugin.json. That file then fails json.Unmarshal, and the plugin drops out of discovery entirely — a worse outcome than either enabled or disabled.
    • os.Rename instead of replaceFile. On Windows the package uses MoveFileEx(..., MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH) (internal/installtxn/lock_windows.go:29). Go's os.Rename does not pass MOVEFILE_WRITE_THROUGH, so the durability guarantee the package deliberately chose is lost on the platform it was chosen for. The existence of a platform-specific replaceFile is the signal that plain os.Rename was already judged insufficient here.

    Both resolve together: take installtxn.Lock on the plugins root, then publish with installtxn.WriteFileAtomically(resolved, append(data, '\n'), mode). That also deletes the os.Getpid()/UnixNano() temp-name construction, which is only there because the helper was not used.

  3. Toggling rewrites the manifest with its fields reordered.internal/plugins/enabled.go:71

    json.MarshalIndent over a map[string]any emits keys in sorted order, so a single zero plugins disable scrambles a hand-authored file. Measured against the patched build:

    BEFORE                       AFTER
    {                            {
      "name": "demo",              "author": "a",
      "version": "1.0.0",          "description": "z last",
      "description": "z last",     "enabled": false,
      "author": "a",               "name": "demo",
      "enabled": true              "version": "1.0.0"
    }                            }
    

    plugin.json is a file users edit by hand and commit. Reordering it on every toggle produces diff noise unrelated to the change, and for a project plugin under ./.zero/plugins that noise lands in the repository. Preserving order needs either an ordered decode (json.Decoder with Token()) or a targeted textual edit of the single enabled field rather than a full re-marshal. If that is judged too costly for the value, say so in the PR description and in docs/EXTENDING.md so the churn is at least documented rather than surprising — but it should be a stated decision, not a side effect.

Non-blocking

  • Permission fallback widens on a stat failure.internal/plugins/enabled.go:75-78. When os.Stat returns an error the mode silently falls back to 0o644. A manifest that was 0o600 would be republished world-readable. os.ReadFile has already succeeded by that point so the window is narrow, but the repo's convention on security-adjacent state is to fail closed rather than pick a permissive default — returning the stat error would be more consistent. Using installtxn.WriteFileAtomically does not fix this by itself; the mode decision stays yours.
  • Symlinked manifests are replaced rather than followed.internal/plugins/enabled.go:47. filepath.Abs does not resolve symlinks, so if plugin.json is a symlink the rename replaces the link with a regular file. Probably out of scope, but filepath.EvalSymlinks is a one-line guard if you want it.
  • Default target when a plugin exists in both scopes.internal/cli/extensions.go:127. Without --user, LoadOptions{ExcludeProject: false} means project precedence wins, so zero plugins disable X writes the project manifest when both exist. The command does print result.ManifestPath afterwards, which mitigates it, but writePluginsToggleHelp describes only what --user does and not what the default does. One extra line there would remove the ambiguity.
  • No test asserts unrelated manifest fields survive.internal/plugins/enabled_test.go. The four tests cover toggling, idempotence, the --user filter, the missing-plugin error and file mode. None reads the manifest back and checks that name, version, commands and the permission block are byte-identical apart from enabled. Given that the implementation round-trips the whole document through a map, that is the assertion most worth having — and it is the one that would have caught finding 3.

Not covered by this PR

  • Nothing invalidates a running daemon's or TUI's in-memory plugin set after a toggle. zero plugins disable X updates the manifest; whether an already-running session notices is untested and probably a separate issue rather than this PR's job — but worth confirming the expectation before merge.

Not verified

  • Windows. TestSetEnabledPreservesManifestMode is skipped there (noted in the PR description, which is the right call), but the rename path is the part that actually diverges and it has no coverage on that platform either way. I did not cross-compile or run the Windows path.
  • I did not exercise zero plugins enable/disable end-to-end against a real installed plugin; the verification above is through the package and CLI tests.

The CLI layer itself I have no complaints about: flag parsing, the --json path through redaction.RedactValue, the ErrNotInstalled mapping to a usage error rather than a crash, and the already-enabled/now-enabled distinction are all handled properly. Rework the write in enabled.go around installtxn and this becomes an approve.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(plugins): add CLI enable/disable for plugin.json

3 participants