Skip to content

Commit 7cf9cc4

Browse files
committed
feat(profiles): Claude Code permission profiles — python/laravel/node/go/mixed
`ai-config-kit profiles` switches a Claude Code settings.json's permissions.allow + permissions.deny blocks to a curated set tailored to a specific stack. Default is `mixed` (everything). Six built-in profiles under src/ai_config_kit/resources/profiles/: - global inspection-only baseline (Read, Grep, Find, git status, gh queries, docker inspect, brew list). Every project profile extends this. - python pytest, ruff, mypy, uv, pip-audit, git mutations, gh PR/issue creation - laravel composer, artisan, all vendor/bin tools (Pest, PHPUnit, Pint, PHPStan, Rector), Sail. Denies migrate:fresh + db:wipe at the project level. - node npm/pnpm/yarn/bun, TS toolchain, framework CLIs (next, vite, astro), Vitest/Playwright - go go toolchain, gofmt/goimports, golangci-lint, govulncheck - mixed everything: Python + PHP + JS + Go + network diagnostics + Docker (default) Library surface: - ClaudeConfig.profiles_list() -> ProfilesListReport - ClaudeConfig.profiles_show(name) -> dict (parent profiles merged via the _meta.extends chain; allow/deny concatenated + de-duped; _meta dropped from on-disk output) - ClaudeConfig.profiles_apply(name, scope='project'|'global', dry_run=True) -> ProfilesApplyReport Existing settings.json is backed up to settings.json.before-profile before overwrite. Audit-logged event: profiles_apply. CLI: - ai-config-kit profiles list [--json] - ai-config-kit profiles show [NAME] - ai-config-kit profiles apply [NAME] [--scope project|global] [--apply] 10 new tests (294 total): list/default/show/extends-chain/unknown/ de-dup/dry-run/write/backup/invalid-scope. docs/profiles.md: full design + design-principles + usage.
1 parent af07e92 commit 7cf9cc4

11 files changed

Lines changed: 1444 additions & 0 deletions

File tree

docs/profiles.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Claude Code permission profiles
2+
3+
`ai-config-kit profiles` switches the `permissions.allow` + `permissions.deny`
4+
blocks of a Claude Code `settings.json` to a curated set tailored to a
5+
specific stack. Six profiles ship today.
6+
7+
## What's a profile?
8+
9+
A small JSON file under `ai_config_kit/resources/profiles/` listing
10+
allow + deny patterns. Profiles can `_meta.extends` other profiles to
11+
inherit their allow + deny lists. The resolver unions both lists and
12+
de-duplicates while preserving order.
13+
14+
## Built-in profiles
15+
16+
| Name | Scope | Summary |
17+
|---|---|---|
18+
| `global` | global | Inspection-only baseline (Read, Grep, Find, git status, gh queries). Walks into any directory safely. |
19+
| `python` | project | Adds pytest, ruff, mypy, uv, pip-audit, git mutations, gh PR/issue creation. |
20+
| `laravel` | project | Composer + artisan + vendor/bin (Pest, PHPUnit, Pint, PHPStan, Rector), Sail. Denies `migrate:fresh` + `db:wipe`. |
21+
| `node` | project | npm/pnpm/yarn/bun, TS toolchain, framework CLIs (next, vite, astro), Vitest/Playwright. |
22+
| `go` | project | go toolchain, gofmt/goimports, golangci-lint, govulncheck. |
23+
| `mixed` | project | **Default.** Everything: Python + PHP + JS + Go + network diagnostics + Docker. |
24+
25+
Every project profile extends `global`, so the baseline read/inspect
26+
permissions always apply.
27+
28+
## Usage
29+
30+
```bash
31+
# Inspect what's available
32+
ai-config-kit profiles list
33+
34+
# See what a profile resolves to (full JSON, parent profiles merged)
35+
ai-config-kit profiles show # default: mixed
36+
ai-config-kit profiles show python
37+
ai-config-kit profiles show laravel | jq .
38+
39+
# Write to the project's <src_dir>/settings.json (dry-run by default)
40+
ai-config-kit profiles apply python
41+
ai-config-kit profiles apply python --apply
42+
43+
# Write to the global ~/.claude/settings.json
44+
ai-config-kit profiles apply global --scope global --apply
45+
```
46+
47+
`--json` flag works on `profiles list` for scripting.
48+
49+
## Architecture
50+
51+
```text
52+
permissions:
53+
allow: [<global allow> | <profile-specific allow>]
54+
deny: [<global deny> | <profile-specific deny>]
55+
```
56+
57+
When you run `profiles apply mixed --scope project`, the resolver:
58+
59+
1. Reads `mixed.json`.
60+
2. Recursively reads every name in `_meta.extends` (currently just `global`).
61+
3. Concatenates parent + self for `permissions.allow` and `permissions.deny`.
62+
4. De-duplicates each list while preserving first-seen order.
63+
5. Drops the `_meta` block (it's internal-only; Claude Code never reads it).
64+
6. Backs up any existing `settings.json` to `settings.json.before-profile`.
65+
7. Writes the resolved JSON.
66+
67+
## Design principles (lifted from the proposal)
68+
69+
1. **Global is inspection-only.** Read, grep, find, status — everything
70+
that observes without changing. A fresh checkout + Claude can read
71+
but not write.
72+
2. **Project configs add write capability.** Edit, Write, language
73+
toolchain, git mutations, PR creation. Each project explicitly opts in.
74+
3. **Deny rules are layered.** Global denies catch system-destructive
75+
patterns; project denies catch project-specific footguns
76+
(`migrate:fresh`, `db:wipe`).
77+
4. **Deny beats allow.** When the same pattern appears in both,
78+
Claude Code's resolver picks deny. Use this to safely broad-allow
79+
like `Bash(git:*)` while denying force-push variants.
80+
5. **Publishing is never auto-approved.** Every profile denies
81+
`twine upload`, `npm publish`, `cargo publish`, `composer publish`,
82+
`gem push`, `goreleaser release`, `gh release create`. Irreversible
83+
actions always prompt.
84+
85+
## Local overrides
86+
87+
The profile lands at `<src_dir>/settings.json`. For per-machine personal
88+
overrides, use Claude Code's `<src_dir>/settings.local.json`
89+
(git-ignored convention) — it merges on top of the profile-installed
90+
file at runtime.
91+
92+
## Verifying
93+
94+
After `profiles apply --apply`:
95+
96+
```bash
97+
claude config list
98+
```
99+
100+
shows the merged effective config. Look for your profile's allow
101+
patterns and the global denies.
102+
103+
## Custom profiles
104+
105+
Today the resolver only loads built-in resource files. To ship a
106+
custom profile, copy one of the built-ins into
107+
`<src_dir>/decisions/your-profile.json` and apply it via the
108+
`decisions install <url>` flow (Phase B) — though that path
109+
overwrites pack files, not settings.json. A first-class
110+
`ai-config-kit profiles add <name> <path>` verb is queued for v0.6.

src/ai_config_kit/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
ListingReport,
4646
MemoryCleanReport,
4747
PermissionFinding,
48+
ProfilesApplyReport,
49+
ProfilesListEntry,
50+
ProfilesListReport,
4851
ProjectFile,
4952
ProjectInstallReport,
5053
Prompter,
@@ -89,6 +92,9 @@
8992
"ListingReport",
9093
"MemoryCleanReport",
9194
"PermissionFinding",
95+
"ProfilesApplyReport",
96+
"ProfilesListEntry",
97+
"ProfilesListReport",
9298
"ProjectFile",
9399
"ProjectInstallReport",
94100
"Prompter",

src/ai_config_kit/cli.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,51 @@ def _build_parser() -> argparse.ArgumentParser:
288288
help="Actually write. Default is dry-run.",
289289
)
290290

291+
# profiles (Claude Code permission profiles)
292+
p_profiles = sub.add_parser(
293+
"profiles",
294+
help=(
295+
"Switch Claude Code permission profiles "
296+
"(python / laravel / node / go / mixed)."
297+
),
298+
)
299+
prof_sub = p_profiles.add_subparsers(dest="profiles_cmd", required=True)
300+
prof_sub.add_parser(
301+
"list",
302+
help="List built-in profiles + their summaries.",
303+
)
304+
p_prof_show = prof_sub.add_parser(
305+
"show",
306+
help="Print a resolved profile's settings JSON.",
307+
)
308+
p_prof_show.add_argument(
309+
"name",
310+
nargs="?",
311+
default=None,
312+
help="Profile name (default: mixed).",
313+
)
314+
p_prof_apply = prof_sub.add_parser(
315+
"apply",
316+
help="Write a profile to settings.json. Dry-run unless --apply.",
317+
)
318+
p_prof_apply.add_argument(
319+
"name",
320+
nargs="?",
321+
default=None,
322+
help="Profile name (default: mixed).",
323+
)
324+
p_prof_apply.add_argument(
325+
"--scope",
326+
choices=["project", "global"],
327+
default="project",
328+
help="Where to write: project (<src_dir>/settings.json, default) or global (~/.claude/settings.json).",
329+
)
330+
p_prof_apply.add_argument(
331+
"--apply",
332+
action="store_true",
333+
help="Actually write. Default is dry-run.",
334+
)
335+
291336
# status / doctor / validate
292337
sub.add_parser("status", help="Tracked + untracked + git state.")
293338
p_doctor = sub.add_parser("doctor", help="Verify symlink health.")
@@ -626,6 +671,27 @@ def main(argv: list[str] | None = None) -> int:
626671
print(sm_report.summary())
627672
return 0
628673

674+
if args.cmd == "profiles":
675+
if args.profiles_cmd == "list":
676+
pl_report = cfg.profiles_list()
677+
if args.json:
678+
print(json.dumps(pl_report.to_json_dict(), indent=2))
679+
else:
680+
print(pl_report.summary())
681+
return 0
682+
if args.profiles_cmd == "show":
683+
resolved = cfg.profiles_show(args.name)
684+
# Strip _meta from CLI output: it's internal-only.
685+
payload = {k: v for k, v in resolved.items() if k != "_meta"}
686+
print(json.dumps(payload, indent=2))
687+
return 0
688+
if args.profiles_cmd == "apply":
689+
pa_report = cfg.profiles_apply(
690+
args.name, scope=args.scope, dry_run=not args.apply
691+
)
692+
print(pa_report.summary())
693+
return 0
694+
629695
if args.cmd == "memory" and args.memory_cmd == "clean":
630696
report = cfg.memory_clean(
631697
older_than_days=args.older_than,

0 commit comments

Comments
 (0)