Fixed setups

This commit is contained in:
Henner M. Kruse
2026-08-04 11:15:24 +02:00
parent e485d33f00
commit 6c6fc35a1f
9 changed files with 609 additions and 288 deletions
+42 -22
View File
@@ -5,8 +5,10 @@ description: Manages git operations (status, log, diff, show, fetch, remote, bra
# Git Manager # Git Manager
This skill wraps all git operations in a single script, This skill wraps all git operations in a single script, permanently
`scripts/git_cmd.sh`, so that: installed at `~/.agent-skills/git-manager/bin/git_cmd.sh` by this plugin's
setup (not run from wherever this skill's own files happen to live — see
"Setup and permissions" below for why), so that:
- There is never a need for `cd <path> && git ...` to operate on a - There is never a need for `cd <path> && git ...` to operate on a
non-default repository — pass `--repo <name>` instead. non-default repository — pass `--repo <name>` instead.
@@ -26,28 +28,44 @@ back to prompts (or, worse, slipping through on a stale broad rule).
Always use: Always use:
```bash ```bash
<skill-dir>/scripts/git_cmd.sh <subcommand> [--repo <name>] [-- <git-args...>] ~/.agent-skills/git-manager/bin/git_cmd.sh <subcommand> [--repo <name>] [-- <git-args...>]
``` ```
- `<subcommand>` must be one of: `status`, `log`, `diff`, `show`, `fetch`, - `<subcommand>` must be one of: `status`, `log`, `diff`, `show`, `fetch`,
`remote`, `branch`, `checkout`, `add`, `commit`, `push`, `pull`. Anything `remote`, `branch`, `checkout`, `add`, `commit`, `push`, `pull`. Anything
else is rejected by the script itself before git runs. else is rejected by the script itself before git runs.
- `--repo <name>` is optional. Omit it to operate on the git repository - `--repo <name>` targets a specific pre-configured repository. The script
containing the current working directory (this is the normal case for a itself (not this skill's own logic) decides what happens if it's
single-repo Claude Code project/session). Use it to target a different, omitted: with exactly one repo registered in
pre-configured repository without changing the working directory — e.g. `~/.agent-skills/git-manager/config.json`, that one is used
a separate Obsidian vault repo alongside a code repo in the same session. automatically; with more than one, the script errors and lists the
registered names so you can retry with `--repo`; with none registered,
it errors saying to register one first. The current working directory
is never consulted for this — whether it happens to be a git repository
itself is not a signal this skill acts on. If the task names a repo
explicitly, pass `--repo <name>` for that one regardless of how many are
registered.
- Everything after `--` is passed through to `git` literally (e.g. - Everything after `--` is passed through to `git` literally (e.g.
`-- -m "commit message"`, `-- --oneline -10`, `-- origin main`). `-- -m "commit message"`, `-- --oneline -10`, `-- origin main`).
- Don't pre-check whether `~/.agent-skills/git-manager/bin/git_cmd.sh`
exists with a separate command (e.g. `test -x ... && ...`) before
calling it — that's exactly the kind of chained command this skill exists
to avoid, and it'll trigger the anti-chaining hook if installed. Just
invoke it directly. If it fails with "No such file or directory" (or
similar), that means setup hasn't been run for this tool yet — tell the
user to run it first (for Claude Code: `/git-manager:setup`) rather than
trying to invoke the script from wherever this skill's own files happen
to be mounted (a plugin cache, a symlink target, etc.) — that location
isn't guaranteed stable and isn't what gets whitelisted.
Examples: Examples:
```bash ```bash
<skill-dir>/scripts/git_cmd.sh status ~/.agent-skills/git-manager/bin/git_cmd.sh status
<skill-dir>/scripts/git_cmd.sh log -- --oneline -10 ~/.agent-skills/git-manager/bin/git_cmd.sh log -- --oneline -10
<skill-dir>/scripts/git_cmd.sh add -- -A ~/.agent-skills/git-manager/bin/git_cmd.sh add -- -A
<skill-dir>/scripts/git_cmd.sh commit -- -m "Update DNS notes" ~/.agent-skills/git-manager/bin/git_cmd.sh commit -- -m "Update DNS notes"
<skill-dir>/scripts/git_cmd.sh push --repo homelab-notes -- origin main ~/.agent-skills/git-manager/bin/git_cmd.sh push --repo homelab-notes -- origin main
``` ```
Never call `git` directly, and never use `cd` to switch into a different Never call `git` directly, and never use `cd` to switch into a different
@@ -62,11 +80,13 @@ purpose as a guardrail, not an oversight.
If a task refers to a named repo (e.g. "push the homelab-notes vault") and If a task refers to a named repo (e.g. "push the homelab-notes vault") and
`~/.agent-skills/git-manager/config.json` doesn't have an entry for it yet, `~/.agent-skills/git-manager/config.json` doesn't have an entry for it yet,
ask the user for its absolute path and add it: ask the user for its absolute path and add it. Use the Read/Write file
tools for this, not `cat`/Bash — the whitelisted permission for this file
```bash is `Read(~/.agent-skills/git-manager/config.json)`, which covers the Read
cat ~/.agent-skills/git-manager/config.json 2>/dev/null file tool, not a Bash `cat` invocation of the same path (those are
``` separate permission checks in Claude Code, even though they access the
same file). Read the file (it may not exist yet, that's fine — treat that
as no repos registered):
```json ```json
{ {
@@ -77,9 +97,9 @@ cat ~/.agent-skills/git-manager/config.json 2>/dev/null
} }
``` ```
Merge new entries in rather than overwriting existing ones. This file is Merge new entries in rather than overwriting existing ones. At least one
optional — omitting `--repo` and relying on the current working directory repo must be registered for this skill to do anything there is no
works without any configuration at all. current-working-directory fallback.
## Setup and permissions ## Setup and permissions
@@ -89,7 +109,7 @@ Host-specific setup (permissions whitelist, hooks) lives under `vendor/<tool>/`
in this repo, not in this skill itself. For Claude Code, see in this repo, not in this skill itself. For Claude Code, see
`vendor/claude-code/plugins/git-manager/commands/setup.md` `vendor/claude-code/plugins/git-manager/commands/setup.md`
(`/git-manager:setup`), which whitelists exactly one command — (`/git-manager:setup`), which whitelists exactly one command —
`Bash(<skill-dir>/scripts/git_cmd.sh:*)` — and optionally installs a `Bash(~/.agent-skills/git-manager/bin/git_cmd.sh:*)` — and optionally installs a
PreToolUse hook that forces a normal confirmation prompt (not a silent PreToolUse hook that forces a normal confirmation prompt (not a silent
block, not a silent allow) for any Bash call containing shell chaining block, not a silent allow) for any Bash call containing shell chaining
operators (`&&`, `;`, `|`, backticks, `$(...)`), as a safety net against operators (`&&`, `;`, `|`, backticks, `$(...)`), as a safety net against
+36 -24
View File
@@ -17,41 +17,53 @@ is_allowed_subcommand() {
return 1 return 1
} }
# Resolve a repo's absolute path. # Resolve a repo's absolute path. Only ever looks at configured repos —
# never falls back to the current working directory, regardless of
# whether it happens to be a git repo itself. This is deliberate: the
# current directory being a git repo is not a signal this script acts on.
# - If a name is given, look it up in the config file's "repos" array. # - If a name is given, look it up in the config file's "repos" array.
# - If no name is given, default to $PWD, but only if it's actually inside # - If no name is given, look at the config: exactly one registered repo
# a git work tree. # is used automatically; zero or more than one is an error requiring an
# Never accepts a raw path from the caller directly — named repos always go # explicit --repo.
# through the config file, so the whitelisted script can't be pointed at an # Never accepts a raw path from the caller directly — only ever a name
# arbitrary directory outside what's configured (or the current project # resolved through the config file, so the whitelisted script can't be
# directory Claude Code itself already scoped the session to). # pointed at an arbitrary directory outside what's configured.
resolve_repo_path() { resolve_repo_path() {
local repo_name="${1:-}" local repo_name="${1:-}"
if [ -n "$repo_name" ]; then if [ ! -f "$CONFIG_FILE" ]; then
if [ ! -f "$CONFIG_FILE" ]; then echo "Error: no configuration found at $CONFIG_FILE. Register at least one repo first (run setup, or ask to register one)." >&2
echo "Error: no configuration found at $CONFIG_FILE. Run setup first, or omit the repo name to use the current directory." >&2 exit 1
exit 1 fi
fi if ! command -v python3 >/dev/null 2>&1; then
if ! command -v python3 >/dev/null 2>&1; then echo "Error: python3 is required to parse the config file." >&2
echo "Error: python3 is required to parse the config file." >&2 exit 1
exit 1 fi
fi
python3 - "$CONFIG_FILE" "$repo_name" << 'PYEOF' python3 - "$CONFIG_FILE" "$repo_name" << 'PYEOF'
import json, sys import json, sys
config_file, repo_name = sys.argv[1], sys.argv[2] config_file, repo_name = sys.argv[1], sys.argv[2]
with open(config_file) as f: with open(config_file) as f:
cfg = json.load(f) cfg = json.load(f)
repos = cfg.get("repos", []) repos = cfg.get("repos", [])
match = [r for r in repos if r.get("name") == repo_name]
if not match: if repo_name:
sys.stderr.write(f"Error: no repo named '{repo_name}' in config.\n") match = [r for r in repos if r.get("name") == repo_name]
if not match:
names = ", ".join(r.get("name", "?") for r in repos) or "(none)"
sys.stderr.write(f"Error: no repo named '{repo_name}' in config. Registered: {names}\n")
sys.exit(1)
print(match[0]["path"])
elif len(repos) == 1:
print(repos[0]["path"])
elif len(repos) == 0:
sys.stderr.write("Error: no repos registered in config. Register at least one first.\n")
sys.exit(1)
else:
names = ", ".join(r.get("name", "?") for r in repos)
sys.stderr.write(f"Error: multiple repos configured ({names}); specify one with --repo <name>.\n")
sys.exit(1) sys.exit(1)
print(match[0]["path"])
PYEOF PYEOF
else
echo "$PWD"
fi
} }
# Validate that a resolved path is a real, existing directory that is # Validate that a resolved path is a real, existing directory that is
+25 -15
View File
@@ -16,11 +16,12 @@ research or study notes).
### 1. Check configuration (always first) ### 1. Check configuration (always first)
Configuration lives in `~/.agent-skills/obsidian-vault-kb/config.json`. Before any Configuration lives in `~/.agent-skills/obsidian-vault-kb/config.json`. Before any
substantive action, check whether this file exists: substantive action, check whether this file exists using the Read file
tool, not `cat`/Bash — the whitelisted permission for this file is
```bash `Read(~/.agent-skills/obsidian-vault-kb/config.json)`, which covers the
cat ~/.agent-skills/obsidian-vault-kb/config.json 2>/dev/null Read file tool, not a Bash `cat` invocation of the same path (those are
``` separate permission checks in Claude Code, even though they access the
same file).
**If the file exists:** Use the configuration from it (vault path(s), mode) **If the file exists:** Use the configuration from it (vault path(s), mode)
and go straight to step 2 (search). Do not ask again. and go straight to step 2 (search). Do not ask again.
@@ -65,26 +66,35 @@ especially when using tags/frontmatter/wikilinks.
Short version of the approach: Short version of the approach:
1. Get an overview of the vault structure: 1. Get an overview of the vault structure:
`<skill-dir>/scripts/vault_index.sh [vault-name]` — returns folders as `~/.agent-skills/obsidian-vault-kb/bin/vault_index.sh [vault-name]` — returns folders as
rough categories, note counts, existing frontmatter tags, and possible rough categories, note counts, existing frontmatter tags, and possible
index/MOC files. index/MOC files.
2. Targeted full-text search with 2. Targeted full-text search with
`<skill-dir>/scripts/vault_search.sh "<query>" [vault-name] [subfolder]` `~/.agent-skills/obsidian-vault-kb/bin/vault_search.sh "<query>" [vault-name] [subfolder]`
— scoped to the most plausible subfolder from step 1 where useful. — scoped to the most plausible subfolder from step 1 where useful.
3. For a specific note, follow backlinks with 3. For a specific note, follow backlinks with
`<skill-dir>/scripts/vault_backlinks.sh "<note-name>" [vault-name]` to `~/.agent-skills/obsidian-vault-kb/bin/vault_backlinks.sh "<note-name>" [vault-name]` to
find related notes that link to it. find related notes that link to it.
4. Open matching files (via the `view`/read tool, not a shell command) to 4. Open matching files (via the `view`/read tool, not a shell command) to
read their frontmatter (tags, `status`, `aliases`) and content in full read their frontmatter (tags, `status`, `aliases`) and content in full
once a promising candidate has been found. once a promising candidate has been found.
These three scripts always resolve the vault path from These three scripts (permanently installed at
`~/.agent-skills/obsidian-vault-kb/config.json` themselves and refuse to operate `~/.agent-skills/obsidian-vault-kb/bin/` by this plugin's setup, not run
outside the configured vault — they never take a raw filesystem path as an from wherever this skill's own files happen to live) always resolve the
argument. This is intentional: it's what allows them (and only them) to be vault path from `~/.agent-skills/obsidian-vault-kb/config.json` themselves
safely whitelisted in Claude Code's permissions without granting broader and refuse to operate outside the configured vault — they never take a raw
shell/filesystem access. See "Setup and permissions" below and filesystem path as an argument. This is intentional: it's what allows them
`references/search-strategy.md` for manual fallback commands (`find`, `rg`, (and only them) to be safely whitelisted in Claude Code's permissions
without granting broader shell/filesystem access. Don't pre-check whether
`~/.agent-skills/obsidian-vault-kb/bin/` exists with a separate command
(e.g. `test -d ... && ...`) before calling one of these scripts — that's
exactly the kind of chained command this design exists to avoid, and it'll
trigger the anti-chaining hook if installed. Just invoke the script
directly. If it fails with "No such file or directory" (or similar), setup
hasn't been run for this tool yet — tell the user to run it first (for
Claude Code: `/obsidian-vault-kb:setup`). See "Setup and permissions" below
and `references/search-strategy.md` for manual fallback commands (`find`, `rg`,
`grep`) for cases the scripts don't cover — those are not whitelisted and `grep`) for cases the scripts don't cover — those are not whitelisted and
will prompt for confirmation, by design. will prompt for confirmation, by design.
+77 -118
View File
@@ -3,135 +3,94 @@ description: Set up the git-manager skill (permissions whitelist, optional anti-
argument-hint: [repo-name] [repo-path] argument-hint: [repo-name] [repo-path]
--- ---
Set up the `git-manager` skill end to end. As part of the `git-manager` Set up the `git-manager` skill. As part of the `git-manager` plugin, this
plugin, this command is automatically namespaced by Claude Code and invoked command is automatically namespaced by Claude Code and invoked as
as `/git-manager:setup`. Follow these steps in order. `/git-manager:setup`.
## 1. Determine the skill's install directory Almost everything about this setup — reading/writing the skill's config,
merging permissions and the hook into settings.json, setting executable
bits — happens inside one bundled script, `scripts/setup.sh`, which you run
as a **single** Bash call. That script also copies the wrapper script(s) it
needs to a stable, self-controlled location
(`~/.agent-skills/git-manager/bin/`) before writing any permissions, so the
permission rule never has to change again across future plugin updates —
only this first run (and, optionally, a re-run after a plugin update) needs
to locate the plugin itself.
This plugin's `skills/git-manager/` is a symlink into the repo's ## 1. Ask the user — three selectable questions first, repo path last
tool-neutral `skills/git-manager/` directory (the single source of truth
for `SKILL.md` and `scripts/`, shared across all vendor adapters in this **1a. First turn: three questions via the selection UI, fixed options for
repo). Resolve it to its real, absolute, symlink-free path: all three:**
- Whether to register any repos at all: yes/no.
- Settings scope: `project` (`.claude/settings.json` in the current
project) or `user` (`~/.claude/settings.json`). Mention: if they work
across many separate repos/projects, `user` avoids repeating setup per
project.
- Anti-chaining hook: yes/no, after explaining it never silently blocks or
silently allows anything, it only forces the *normal* confirmation
prompt when a Bash command contains shell chaining operators (`&&`, `;`,
`|`, backticks, `$(...)`), as a safety net given Claude Code's Bash
allow-list matching has had bugs around compound commands (see
`hooks/force-ask-on-chaining.sh` for details).
**1b. Only if the answer to "register any repos" was yes: a second turn,
plain chat message, no tool call.** Ask exactly: "Which repos should
git-manager know about? Provide as `name=path` pairs." End your turn right
after asking this, with nothing else queued up, so the user's reply is
what actually gets collected before you continue. (Only a tool call
reliably pauses for a reply in this environment; plain text followed
immediately by a tool call in the same turn does not wait — that's what
caused this question to appear skipped in an earlier version of this
command.) If the answer to "register any repos" was no, skip this
sub-step entirely — don't ask it at all.
## 2. Run the setup script — one command
Claude Code installs plugins added from a local marketplace at
`~/.claude/plugins/cache/<marketplace-name>/<plugin-name>/<plugin-version>/`.
For this repo's marketplace (`skill-repo`) and this plugin's current
version (`1.0.0` — check this plugin's own `.claude-plugin/plugin.json` if
this has since changed), that's:
```bash ```bash
realpath <plugin-dir>/skills/git-manager ~/.claude/plugins/cache/skill-repo/git-manager/1.0.0/scripts/setup.sh \
--settings-scope <project|user> \
[--project-dir <path>] \
[--repo <name>=<path> ...] \
[--install-hook]
``` ```
This resolved path is `<SKILL_DIR>` for the rest of this setup. Include `--repo name=path` once per repo from step 1, `--install-hook` only
if the user opted in, and `--project-dir` only if `--settings-scope
project` and the project isn't the current working directory. Try this
path directly first — don't `find`/`ls` preemptively.
## 2. Optionally register named repos If that exact path doesn't exist (installed version differs from `1.0.0`,
differently named marketplace, or a future Claude Code cache layout
If `$ARGUMENTS` contains a repo name and path, or the user wants to change), fall back to exactly one bounded lookup instead of guessing
pre-register repos now (e.g. an Obsidian vault repo used alongside a code further:
repo), write/merge them into
`~/.agent-skills/git-manager/config.json`:
```bash ```bash
mkdir -p ~/.agent-skills/git-manager find ~/.claude/plugins/cache/skill-repo/git-manager -maxdepth 2 -type d 2>/dev/null
cat > ~/.agent-skills/git-manager/config.json << 'EOF'
{
"repos": [
{"name": "<short-name>", "path": "<absolute-repo-path>"}
]
}
EOF
``` ```
If the file already exists, merge new entries in rather than overwriting. and construct the same `scripts/setup.sh` call using whatever version
This step is entirely optional — the skill works without any named repos, directory that reveals. This is expected to prompt for approval the first
operating on the current working directory by default. time — there's no way to pre-whitelist it before the setup that establishes
the whitelist has run.
## 3. Generate and merge the minimal permissions whitelist ## 3. Relay the result
Exactly one command needs whitelisting — everything else about this skill's The script prints a JSON summary (stable script location, settings file
safety comes from the wrapper script's own subcommand allowlist and repo written, config file, whether the hook was installed, repos registered,
resolution, not from a long list of Bash rules: the exact permission rules added, and a note about session reload).
Present that summary to the user in plain language — don't re-read any of
those files yourself to "double check"; the script's own output already
reflects exactly what's on disk.
- `Bash(<SKILL_DIR>/scripts/git_cmd.sh:*)` Do **not** follow this with a test invocation of `git_cmd.sh` — Claude Code
- `Read(~/.agent-skills/git-manager/config.json)` loads permissions at session start and doesn't always pick up changes made
by its own file edits within the same session, so an immediate test can
Steps: prompt (or not) in a way that doesn't reliably indicate success either way.
If a real command later still prompts, the fix is a fresh session, not
1. Locate the target settings file: prefer the project-level re-running this setup — the configuration is already correctly saved.
`.claude/settings.json` if a project is open, otherwise the user-level
`~/.claude/settings.json`. Ask the user which one they want if unclear —
note that if they work across many separate git repos/projects, the
user-level file avoids repeating this setup per project.
2. If the target file doesn't exist yet, create it containing just these
two entries under `permissions.allow`.
3. If it exists, read it first and merge: add only entries not already
present. Don't duplicate, don't remove or overwrite unrelated existing
permissions.
4. Show the user exactly what was added before writing.
Do **not** whitelist a generic `Bash(git:*)` or anything targeting `cd`.
Do **not** whitelist writing to the config file — that keeps prompting for
confirmation, so registering a new repo is always a visible, confirmed
action.
## 4. Offer the anti-chaining hook
Explain to the user: this hook never silently blocks and never silently
allows anything. It only ever forces the *normal* confirmation prompt
(`permissionDecision: "ask"`) when a Bash command contains shell chaining
or substitution operators (`&&`, `;`, `|`, backticks, `$(...)`). This exists
because Claude Code's own Bash allow-list matching has had bugs where
compound commands either bypass per-command checks entirely, or where a
chain of individually-allowed commands still isn't recognized as such — see
`hooks/force-ask-on-chaining.sh` for the reasoning and a link to the
relevant upstream issue. With this hook installed, if a chained command
ever gets constructed anyway (it shouldn't, since `git_cmd.sh` removes the
need for `cd`/chaining), the user will always see a normal approval prompt
for it rather than it silently going through or silently failing.
Ask the user whether to install it. If yes:
1. Locate `hooks/force-ask-on-chaining.sh` next to this plugin's
`plugin.json` and note its absolute path, `<HOOK_PATH>`.
2. Ensure it's executable: `chmod +x <HOOK_PATH>`.
3. Merge this into the same settings file chosen in step 3 (project or
user-level — ask if unclear, but note this hook is most useful applied
broadly at the user level, since it's about Bash hygiene in general, not
specific to git):
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "<HOOK_PATH>"
}
]
}
]
}
}
```
Merge into any existing `hooks.PreToolUse` array rather than overwriting it
— don't remove other existing PreToolUse hooks for the `Bash` matcher or
other matchers.
If the user declines, skip this step entirely; the whitelist from step 3
still works on its own, it just doesn't have this extra safety net.
## 5. Make the wrapper script executable
```bash
chmod +x <SKILL_DIR>/scripts/git_cmd.sh
```
## 6. Confirm and offer a test run
Summarize to the user:
- Any repos registered in step 2
- The exact permission entries added, and where
- Whether the anti-chaining hook was installed, and where
Then offer to run `<SKILL_DIR>/scripts/git_cmd.sh status` (against the
current directory, or a registered repo) as a quick sanity check.
@@ -1,5 +1,5 @@
{ {
"_comment": "Template for the git-manager skill's Claude Code permissions. <SKILL_DIR> must be replaced with the absolute, symlink-resolved path of skills/git-manager (see commands/setup.md step 1) before merging into settings.json. Exactly one Bash rule is whitelisted — the wrapper script itself — never a generic Bash(git:*) or anything targeting cd. The wrapper script enforces its own subcommand allowlist (status, log, diff, show, fetch, remote, branch, checkout, add, commit, push, pull) and resolves repo paths only from ~/.agent-skills/git-manager/config.json or the current working directory, never from an arbitrary caller-supplied path.", "_comment": "Reference only — setup.sh generates and writes these exact rules itself, so this file is no longer read during setup. Kept here for auditing/documentation: this is what /git-manager:setup will add to your permissions.allow, with <SKILL_DIR> substituted for the real, symlink-resolved skills/git-manager path. Exactly one Bash rule — the wrapper script itself — never a generic Bash(git:*) or anything targeting cd. The wrapper script enforces its own subcommand allowlist (status, log, diff, show, fetch, remote, branch, checkout, add, commit, push, pull) and resolves repo paths only from named entries in ~/.agent-skills/git-manager/config.json, never from the current working directory or an arbitrary caller-supplied path.",
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(<SKILL_DIR>/scripts/git_cmd.sh:*)", "Bash(<SKILL_DIR>/scripts/git_cmd.sh:*)",
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
# One-shot setup for the git-manager plugin.
#
# Order matters here, deliberately: the permission rule is established
# FIRST, against a stable path we control ourselves
# (~/.agent-skills/git-manager/bin/), not against Claude Code's internal
# plugin cache path (~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/),
# which changes on every plugin update and is why earlier versions of this
# setup needed to "locate itself" at all. Configuration (repos, hook) comes
# after, and re-running this script after a plugin update re-syncs the
# stable copy without ever touching the permission rule again.
#
# Usage:
# setup.sh --settings-scope (project|user) [--project-dir <path>]
# [--repo <name>=<path> ...] [--install-hook]
#
# Prints a JSON summary of what changed to stdout at the end.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SKILL_DIR="$(realpath "$PLUGIN_DIR/skills/git-manager")"
SOURCE_HOOK_PATH="$PLUGIN_DIR/hooks/force-ask-on-chaining.sh"
if ! command -v python3 >/dev/null 2>&1; then
echo "Error: python3 is required." >&2
exit 1
fi
# --- 1. Establish the stable, self-controlled target location and copy
# the wrapper script (+ its dependency) and the hook there. This is the
# ONLY thing whitelisted permissions will ever point to — never the
# Claude Code plugin cache path, which changes on every version bump. ---
STABLE_DIR="$HOME/.agent-skills/git-manager/bin"
mkdir -p "$STABLE_DIR"
cp "$SKILL_DIR/scripts/git_cmd.sh" "$STABLE_DIR/git_cmd.sh"
cp "$SKILL_DIR/scripts/_lib.sh" "$STABLE_DIR/_lib.sh"
cp "$SOURCE_HOOK_PATH" "$STABLE_DIR/force-ask-on-chaining.sh"
chmod +x "$STABLE_DIR/git_cmd.sh" "$STABLE_DIR/force-ask-on-chaining.sh"
WRAPPER_PATH="$STABLE_DIR/git_cmd.sh"
HOOK_PATH="$STABLE_DIR/force-ask-on-chaining.sh"
# Tilde-form paths for permission rules and SKILL.md's example commands —
# Claude Code matches Bash permission rules against the literal,
# unexpanded command text, so a rule written with the resolved absolute
# $HOME path (e.g. /home/hmk/...) does NOT match a command Claude typed as
# "~/...", even though both point at the same file. Keep both sides in
# tilde form so they actually match.
TILDE_WRAPPER_PATH="~/.agent-skills/git-manager/bin/git_cmd.sh"
TILDE_HOOK_PATH="~/.agent-skills/git-manager/bin/force-ask-on-chaining.sh"
TILDE_CONFIG_FILE="~/.agent-skills/git-manager/config.json"
# --- 2. Parse arguments ---
SETTINGS_SCOPE=""
PROJECT_DIR="$PWD"
INSTALL_HOOK=0
REPOS=()
while [ $# -gt 0 ]; do
case "$1" in
--settings-scope) SETTINGS_SCOPE="$2"; shift 2 ;;
--project-dir) PROJECT_DIR="$2"; shift 2 ;;
--repo) REPOS+=("$2"); shift 2 ;;
--install-hook) INSTALL_HOOK=1; shift ;;
*) echo "Error: unknown argument '$1'" >&2; exit 1 ;;
esac
done
if [ "$SETTINGS_SCOPE" != "project" ] && [ "$SETTINGS_SCOPE" != "user" ]; then
echo "Error: --settings-scope must be 'project' or 'user'" >&2
exit 1
fi
if [ "$SETTINGS_SCOPE" = "project" ]; then
SETTINGS_FILE="$PROJECT_DIR/.claude/settings.json"
else
SETTINGS_FILE="$HOME/.claude/settings.json"
fi
CONFIG_DIR="$HOME/.agent-skills/git-manager"
CONFIG_FILE="$CONFIG_DIR/config.json"
mkdir -p "$CONFIG_DIR"
# --- 3. Write permissions FIRST, against the stable path from step 1 —
# before touching any repo configuration. This is what makes "permissions
# first, then configuration" actually true, per the point of this
# redesign: the target path was already fixed and known before we even
# parsed arguments. ---
mkdir -p "$(dirname "$SETTINGS_FILE")"
python3 - "$SETTINGS_FILE" "$TILDE_WRAPPER_PATH" "$TILDE_CONFIG_FILE" "$HOOK_PATH" "$INSTALL_HOOK" << 'PYEOF'
import json, os, sys
settings_file, tilde_wrapper_path, tilde_config_file, hook_path, install_hook = sys.argv[1:6]
install_hook = install_hook == "1"
settings = {}
if os.path.exists(settings_file):
with open(settings_file) as f:
settings = json.load(f)
perms = settings.setdefault("permissions", {})
allow = perms.setdefault("allow", [])
# Tilde form here on purpose: Claude Code matches permission rules against
# the literal, unexpanded command text a model produces, which uses "~"
# (per SKILL.md), not the resolved absolute $HOME path.
new_rules = [
f"Bash({tilde_wrapper_path}:*)",
f"Read({tilde_config_file})",
]
for rule in new_rules:
if rule not in allow:
allow.append(rule)
if install_hook:
hooks = settings.setdefault("hooks", {})
pre_tool_use = hooks.setdefault("PreToolUse", [])
already_present = any(
entry.get("matcher") == "Bash"
and any(h.get("command") == hook_path for h in entry.get("hooks", []))
for entry in pre_tool_use
)
if not already_present:
pre_tool_use.append({
"matcher": "Bash",
# Real absolute path here (not tilde) — this is executed
# directly by Claude Code as a subprocess, not matched as text,
# so it needs to be an actually-invokable path.
"hooks": [{"type": "command", "command": hook_path}],
})
with open(settings_file, "w") as f:
json.dump(settings, f, indent=2)
f.write("\n")
PYEOF
# --- 4. THEN configuration: write/merge repos, if any were given ---
REPOS_JSON="[]"
if [ "${#REPOS[@]}" -gt 0 ]; then
REPOS_JSON=$(python3 -c '
import json, sys
pairs = sys.argv[1:]
repos = []
for p in pairs:
name, path = p.split("=", 1)
repos.append({"name": name, "path": path})
print(json.dumps(repos))
' "${REPOS[@]}")
fi
python3 - "$CONFIG_FILE" "$REPOS_JSON" << 'PYEOF'
import json, os, sys
config_file, new_repos_json = sys.argv[1], sys.argv[2]
new_repos = json.loads(new_repos_json)
existing = {"repos": []}
if os.path.exists(config_file):
with open(config_file) as f:
existing = json.load(f)
existing_by_name = {r["name"]: r for r in existing.get("repos", [])}
for r in new_repos:
existing_by_name[r["name"]] = r # upsert: new/updated path always wins
existing["repos"] = list(existing_by_name.values())
with open(config_file, "w") as f:
json.dump(existing, f, indent=2)
f.write("\n")
PYEOF
# --- 5. Summary ---
python3 - "$STABLE_DIR" "$SETTINGS_FILE" "$CONFIG_FILE" "$INSTALL_HOOK" "$REPOS_JSON" "$TILDE_WRAPPER_PATH" "$TILDE_CONFIG_FILE" << 'PYEOF'
import json, sys
stable_dir, settings_file, config_file, install_hook, repos_json, tilde_wrapper_path, tilde_config_file = sys.argv[1:8]
print(json.dumps({
"stable_script_location": stable_dir,
"settings_file": settings_file,
"config_file": config_file,
"hook_installed": install_hook == "1",
"repos_registered": json.loads(repos_json),
"permission_rules_added": [f"Bash({tilde_wrapper_path}:*)", f"Read({tilde_config_file})"],
"note": "The permission rule points at a stable location this script controls (~/.agent-skills/git-manager/bin/), not at Claude Code's internal plugin cache — so it survives future plugin updates without changing. It's written in tilde form (~/...) to match the literal, unexpanded command text Claude Code matches against, not the resolved absolute $HOME path. Permissions are written to disk now, but Claude Code loads permissions at session start and does not always pick up changes made by its own file edits within the same session — if a git_cmd.sh call still prompts after this, start a fresh session rather than re-running setup."
}, indent=2))
PYEOF
+68 -107
View File
@@ -3,124 +3,85 @@ description: Set up the obsidian-vault-kb skill (vault path, mode, minimal permi
argument-hint: [vault-path] [mode] argument-hint: [vault-path] [mode]
--- ---
Set up the `obsidian-vault-kb` skill end to end. As part of the Set up the `obsidian-vault-kb` skill. As part of the `obsidian-vault-kb`
`obsidian-vault-kb` plugin, this command is automatically namespaced by plugin, this command is automatically namespaced by Claude Code and invoked
Claude Code and invoked as `/obsidian-vault-kb:setup` — no manual filename as `/obsidian-vault-kb:setup`.
prefixing needed. Follow these steps in order.
## 1. Determine the skill's install directory Almost everything about this setup — validating vault paths, writing the
skill's config, merging permissions into settings.json, setting executable
bits — happens inside one bundled script, `scripts/setup.sh`, which you run
as a **single** Bash call. That script also copies the wrapper scripts it
needs to a stable, self-controlled location
(`~/.agent-skills/obsidian-vault-kb/bin/`) before writing any permissions,
so the permission rules never have to change again across future plugin
updates — only this first run (and, optionally, a re-run after a plugin
update) needs to locate the plugin itself.
This plugin's `skills/obsidian-vault-kb/` is a symlink into the repo's ## 1. Ask the user — selectable questions first, vault path last
tool-neutral `skills/obsidian-vault-kb/` directory (the single source of
truth for `SKILL.md`, `scripts/`, and `references/`, shared across all **1a. First turn: two questions via the selection UI, fixed options for
vendor adapters in this repo). Resolve it to its real, absolute, both:**
symlink-free path: - Mode: `read-only`, `append`, or `maintain` (as defined in SKILL.md —
explain each briefly if unsure).
- Settings scope: `project` (`.claude/settings.json` in the current
project) or `user` (`~/.claude/settings.json`).
**1b. Second turn, plain chat message, no tool call.** Ask exactly:
"What's the path to your vault? Give it a short name too if you're
registering more than one." End your turn right after asking this, with
nothing else queued up, so the user's reply is what actually gets
collected before you continue. (Only a tool call reliably pauses for a
reply in this environment; plain text followed immediately by a tool call
in the same turn does not wait — that's what caused this question to
appear skipped in an earlier version of this command.) At least one vault
is required — `setup.sh` errors out without one — so this question is
always asked, unlike the optional repos question in the git-manager
plugin's setup.
## 2. Run the setup script — one command
Claude Code installs plugins added from a local marketplace at
`~/.claude/plugins/cache/<marketplace-name>/<plugin-name>/<plugin-version>/`.
For this repo's marketplace (`skill-repo`) and this plugin's current
version (`1.0.0` — check this plugin's own `.claude-plugin/plugin.json` if
this has since changed), that's:
```bash ```bash
realpath <plugin-dir>/skills/obsidian-vault-kb ~/.claude/plugins/cache/skill-repo/obsidian-vault-kb/1.0.0/scripts/setup.sh \
--settings-scope <project|user> \
[--project-dir <path>] \
--mode <read-only|append|maintain> \
--vault <name>=<path> [--vault <name>=<path> ...]
``` ```
This resolved path is `<SKILL_DIR>` for the rest of this setup — you'll The script itself validates each vault path exists and is a directory, and
need it exactly for step 4. Use the real path (not the symlink path) so the reports an error if not — no separate validation call needed beforehand.
permissions whitelisted in step 4 keep working even if this plugin Try this path directly first — don't `find`/`ls` preemptively.
directory is relocated relative to the shared `skills/` directory.
## 2. Gather vault path and mode If the exact cache path doesn't exist (installed version differs from
`1.0.0`, differently named marketplace, or a future cache layout change),
If `$ARGUMENTS` contains a vault path and/or mode, use those directly. fall back to exactly one bounded lookup instead of guessing further:
Otherwise ask the user:
1. Absolute path to the Obsidian vault (if there's more than one vault, ask
for a short name per vault too).
2. Mode: `read-only`, `append`, or `maintain` (as defined in SKILL.md —
explain briefly if the user is unsure which to pick).
Validate the path exists and is a directory before continuing:
```bash ```bash
test -d "<vault-path>" && echo OK || echo "MISSING" find ~/.claude/plugins/cache/skill-repo/obsidian-vault-kb -maxdepth 2 -type d 2>/dev/null
``` ```
If missing, ask the user to correct it before proceeding. and construct the same `scripts/setup.sh` call using whatever version
directory that reveals. This is expected to prompt for approval the first
time — there's no way to pre-whitelist it before the setup that establishes
the whitelist has run.
## 3. Write the skill configuration ## 3. Relay the result
The config path is tool-neutral (not Claude-specific), so this same file The script prints a JSON summary (stable script location, settings file
would be reused if another Agent-Skills-compatible tool ran this skill: written, config file, mode, vaults registered, the exact permission rules
added, and a note about session reload). Present that summary to the user
in plain language — don't re-read any of those files yourself to "double
check"; the script's own output already reflects exactly what's on disk.
```bash Do **not** follow this with a test invocation of `vault_index.sh` — Claude
mkdir -p ~/.agent-skills/obsidian-vault-kb Code loads permissions at session start and doesn't always pick up changes
cat > ~/.agent-skills/obsidian-vault-kb/config.json << 'EOF' made by its own file edits within the same session, so an immediate test
{ can prompt (or not) in a way that doesn't reliably indicate success either
"vaults": [ way. If a real command later still prompts, the fix is a fresh session, not
{"name": "<short-name>", "path": "<absolute-vault-path>"} re-running this setup — the configuration is already correctly saved.
],
"mode": "<read-only|append|maintain>"
}
EOF
cat ~/.agent-skills/obsidian-vault-kb/config.json
```
Confirm the written content with the user.
## 4. Generate and merge the minimal permissions whitelist
Read `permissions-whitelist.template.json` (next to this plugin's
`plugin.json`, i.e. `<plugin-dir>/permissions-whitelist.template.json`). It
whitelists exactly three commands, each pinned to its full absolute path —
no generic `Bash(find:*)`, `Bash(rg:*)`, `Bash(cat:*)`, etc., since those
would allow reading anywhere on the filesystem rather than just the vault:
- `Bash(<SKILL_DIR>/scripts/vault_index.sh:*)`
- `Bash(<SKILL_DIR>/scripts/vault_search.sh:*)`
- `Bash(<SKILL_DIR>/scripts/vault_backlinks.sh:*)`
- `Read(~/.agent-skills/obsidian-vault-kb/config.json)`
These three scripts resolve the vault path only from
`~/.agent-skills/obsidian-vault-kb/config.json` and refuse to operate on any
path outside it (see their source), so whitelisting them by exact path does
not grant broader filesystem access — not even to other files in the same
`scripts/` directory, since the wildcard is per-script-file, not per-folder.
Steps:
1. Replace every `<SKILL_DIR>` placeholder in the template with the real,
symlink-resolved absolute path from step 1.
2. Locate the target settings file: prefer the project-level
`.claude/settings.json` if a project is open, otherwise the user-level
`~/.claude/settings.json`. Ask the user which one they want if unclear.
3. If the target file doesn't exist yet, create it containing just the
substituted `permissions.allow` array.
4. If it exists, read it first and merge: add only entries from the
substituted array that aren't already present in the target file's
`permissions.allow` array. Don't duplicate entries, don't remove or
overwrite unrelated existing permissions in the file.
5. Show the user exactly what was added (the four entries above with the
real path filled in) before writing, so they can see precisely what
they're approving.
Do **not** add anything beyond these four entries. In particular, do not
whitelist writing to the vault or to the config file — those keep prompting
for confirmation every time, by design, in `append`/`maintain` mode.
## 5. Make the helper scripts executable
```bash
chmod +x <SKILL_DIR>/scripts/vault_index.sh <SKILL_DIR>/scripts/vault_search.sh <SKILL_DIR>/scripts/vault_backlinks.sh
```
Note this modifies the shared `skills/obsidian-vault-kb/scripts/` files in
the repo (via the resolved real path), not a plugin-local copy — that's
expected, since the symlink means there is only one copy on disk.
## 6. Confirm and offer a test run
Summarize to the user:
- Vault path(s) and mode that were configured
- The exact four permission entries that were added, and where (project vs.
user settings)
- That nothing else was whitelisted — every other command, and any write
into the vault, still prompts for confirmation
Then offer to run `<SKILL_DIR>/scripts/vault_index.sh` as a quick sanity
check, if the user wants to see it working right away.
@@ -1,5 +1,5 @@
{ {
"_comment": "Template for this skill's Claude Code permissions. <SKILL_DIR> must be replaced with the absolute path where this plugin's skills/obsidian-vault-kb directory resolves to (i.e. the real, symlink-resolved path of skills/obsidian-vault-kb inside vendor/claude-code/plugins/obsidian-vault-kb/) before merging into settings.json. The /obsidian-vault-kb:setup command does this substitution automatically. Only the three read-only wrapper scripts are whitelisted, each pinned to its exact absolute path — no generic Bash(find:*), Bash(rg:*), Bash(cat:*), etc. The wrapper scripts themselves resolve the vault path only from ~/.agent-skills/obsidian-vault-kb/config.json and refuse to operate outside it, so whitelisting them does not grant filesystem access beyond the configured vault.", "_comment": "Reference only — setup.sh generates and writes these exact rules itself, so this file is no longer read during setup. Kept here for auditing/documentation: this is what /obsidian-vault-kb:setup will add to your permissions.allow, with <SKILL_DIR> substituted for the real, symlink-resolved skills/obsidian-vault-kb path. Only the three read-only wrapper scripts are whitelisted, each pinned to its exact absolute path — no generic Bash(find:*), Bash(rg:*), Bash(cat:*), etc. The wrapper scripts themselves resolve the vault path only from ~/.agent-skills/obsidian-vault-kb/config.json and refuse to operate outside it, so whitelisting them does not grant filesystem access beyond the configured vault.",
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(<SKILL_DIR>/scripts/vault_index.sh:*)", "Bash(<SKILL_DIR>/scripts/vault_index.sh:*)",
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# One-shot setup for the obsidian-vault-kb plugin.
#
# Order matters here, deliberately: the permission rules are established
# FIRST, against a stable path we control ourselves
# (~/.agent-skills/obsidian-vault-kb/bin/), not against Claude Code's
# internal plugin cache path (which changes on every plugin update).
# Configuration (vaults, mode) comes after. Re-running this script after a
# plugin update re-syncs the stable copy without the permission rules ever
# needing to change again.
#
# Usage:
# setup.sh --settings-scope (project|user) [--project-dir <path>]
# --mode (read-only|append|maintain)
# --vault <name>=<path> [--vault <name>=<path> ...]
#
# Prints a JSON summary of what changed to stdout at the end.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SKILL_DIR="$(realpath "$PLUGIN_DIR/skills/obsidian-vault-kb")"
if ! command -v python3 >/dev/null 2>&1; then
echo "Error: python3 is required." >&2
exit 1
fi
# --- 1. Stable, self-controlled target location. Copy the three wrapper
# scripts and their shared helper there — this is the ONLY location
# permissions will ever point to. ---
STABLE_DIR="$HOME/.agent-skills/obsidian-vault-kb/bin"
mkdir -p "$STABLE_DIR"
for f in vault_index.sh vault_search.sh vault_backlinks.sh _lib.sh; do
cp "$SKILL_DIR/scripts/$f" "$STABLE_DIR/$f"
done
chmod +x "$STABLE_DIR/vault_index.sh" "$STABLE_DIR/vault_search.sh" "$STABLE_DIR/vault_backlinks.sh"
# Tilde-form path for permission rules and SKILL.md's example commands —
# Claude Code matches Bash permission rules against the literal,
# unexpanded command text, so a rule written with the resolved absolute
# $HOME path (e.g. /home/hmk/...) does NOT match a command Claude typed as
# "~/...", even though both point at the same file. Keep both sides in
# tilde form so they actually match.
TILDE_STABLE_DIR="~/.agent-skills/obsidian-vault-kb/bin"
TILDE_CONFIG_FILE="~/.agent-skills/obsidian-vault-kb/config.json"
# --- 2. Parse arguments ---
SETTINGS_SCOPE=""
PROJECT_DIR="$PWD"
MODE=""
VAULTS=()
while [ $# -gt 0 ]; do
case "$1" in
--settings-scope) SETTINGS_SCOPE="$2"; shift 2 ;;
--project-dir) PROJECT_DIR="$2"; shift 2 ;;
--mode) MODE="$2"; shift 2 ;;
--vault) VAULTS+=("$2"); shift 2 ;;
*) echo "Error: unknown argument '$1'" >&2; exit 1 ;;
esac
done
if [ "$SETTINGS_SCOPE" != "project" ] && [ "$SETTINGS_SCOPE" != "user" ]; then
echo "Error: --settings-scope must be 'project' or 'user'" >&2
exit 1
fi
case "$MODE" in
read-only|append|maintain) ;;
*) echo "Error: --mode must be read-only, append, or maintain" >&2; exit 1 ;;
esac
if [ "${#VAULTS[@]}" -eq 0 ]; then
echo "Error: at least one --vault <name>=<path> is required" >&2
exit 1
fi
if [ "$SETTINGS_SCOPE" = "project" ]; then
SETTINGS_FILE="$PROJECT_DIR/.claude/settings.json"
else
SETTINGS_FILE="$HOME/.claude/settings.json"
fi
CONFIG_DIR="$HOME/.agent-skills/obsidian-vault-kb"
CONFIG_FILE="$CONFIG_DIR/config.json"
mkdir -p "$CONFIG_DIR"
# --- 3. Write permissions FIRST, against the stable path from step 1 ---
mkdir -p "$(dirname "$SETTINGS_FILE")"
python3 - "$SETTINGS_FILE" "$TILDE_STABLE_DIR" "$TILDE_CONFIG_FILE" << 'PYEOF'
import json, os, sys
settings_file, tilde_stable_dir, tilde_config_file = sys.argv[1:4]
settings = {}
if os.path.exists(settings_file):
with open(settings_file) as f:
settings = json.load(f)
perms = settings.setdefault("permissions", {})
allow = perms.setdefault("allow", [])
new_rules = [
f"Bash({tilde_stable_dir}/vault_index.sh:*)",
f"Bash({tilde_stable_dir}/vault_search.sh:*)",
f"Bash({tilde_stable_dir}/vault_backlinks.sh:*)",
f"Read({tilde_config_file})",
]
for rule in new_rules:
if rule not in allow:
allow.append(rule)
with open(settings_file, "w") as f:
json.dump(settings, f, indent=2)
f.write("\n")
PYEOF
# --- 4. THEN configuration: validate + write/merge vaults and mode ---
VAULTS_JSON=$(python3 -c '
import json, os, sys
pairs = sys.argv[1:]
vaults = []
for p in pairs:
name, path = p.split("=", 1)
real = os.path.realpath(path)
if not os.path.isdir(real):
sys.stderr.write(f"Error: vault path {path!r} (for {name!r}) is not a directory.\n")
sys.exit(1)
vaults.append({"name": name, "path": real})
print(json.dumps(vaults))
' "${VAULTS[@]}")
python3 - "$CONFIG_FILE" "$VAULTS_JSON" "$MODE" << 'PYEOF'
import json, os, sys
config_file, new_vaults_json, mode = sys.argv[1], sys.argv[2], sys.argv[3]
new_vaults = json.loads(new_vaults_json)
existing = {"vaults": [], "mode": mode}
if os.path.exists(config_file):
with open(config_file) as f:
existing = json.load(f)
existing["mode"] = mode
existing_by_name = {v["name"]: v for v in existing.get("vaults", [])}
for v in new_vaults:
existing_by_name[v["name"]] = v # upsert: new/updated path always wins
existing["vaults"] = list(existing_by_name.values())
with open(config_file, "w") as f:
json.dump(existing, f, indent=2)
f.write("\n")
PYEOF
# --- 5. Summary ---
python3 - "$STABLE_DIR" "$SETTINGS_FILE" "$CONFIG_FILE" "$MODE" "$VAULTS_JSON" "$TILDE_STABLE_DIR" "$TILDE_CONFIG_FILE" << 'PYEOF'
import json, sys
stable_dir, settings_file, config_file, mode, vaults_json, tilde_stable_dir, tilde_config_file = sys.argv[1:8]
print(json.dumps({
"stable_script_location": stable_dir,
"settings_file": settings_file,
"config_file": config_file,
"mode": mode,
"vaults_registered": json.loads(vaults_json),
"permission_rules_added": [
f"Bash({tilde_stable_dir}/vault_index.sh:*)",
f"Bash({tilde_stable_dir}/vault_search.sh:*)",
f"Bash({tilde_stable_dir}/vault_backlinks.sh:*)",
f"Read({tilde_config_file})"
],
"note": "The permission rules point at a stable location this script controls (~/.agent-skills/obsidian-vault-kb/bin/), not at Claude Code's internal plugin cache — so they survive future plugin updates without changing. They're written in tilde form (~/...) to match the literal, unexpanded command text Claude Code matches against, not the resolved absolute $HOME path. Permissions are written to disk now, but Claude Code loads permissions at session start and does not always pick up changes made by its own file edits within the same session — if a vault script call still prompts after this, start a fresh session rather than re-running setup."
}, indent=2))
PYEOF