@letta-ai/letta-code 0.23.11 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letta-ai/letta-code",
3
- "version": "0.23.11",
3
+ "version": "0.24.0",
4
4
  "description": "Letta Code is a CLI tool for interacting with stateful Letta agents from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -535,7 +535,7 @@ Explore based on chosen depth.
535
535
 
536
536
  For medium-to-large repos, parallel exploration is the preferred strategy after your initial scan.
537
537
 
538
- Use parallel subagents to investigate different subsystems simultaneously. Prefer a **read-only exploration subagent** when available. If your environment or user instructions discourage using an exploration subagent, do the equivalent exploration directly with Bash/Glob/Grep/Read.
538
+ Use parallel `general-purpose` subagents to investigate different subsystems simultaneously. If your environment or user instructions discourage using subagents, do the equivalent exploration directly with Bash/Glob/Grep/Read.
539
539
 
540
540
  Good subsystem boundaries include:
541
541
  - `server/`, `client/`, `shared/`
@@ -561,7 +561,7 @@ Launch exploration subagents in a **single message** so they run concurrently.
561
561
  ```
562
562
  # After initial scan reveals key areas, launch parallel explorers in the background:
563
563
  Task({
564
- subagent_type: "explore",
564
+ subagent_type: "general-purpose",
565
565
  description: "Explore API layer",
566
566
  run_in_background: true,
567
567
  prompt: `Read the implementation in src/api/.
@@ -574,7 +574,7 @@ Return:
574
574
  5. file paths worth storing in memory`
575
575
  })
576
576
  Task({
577
- subagent_type: "explore",
577
+ subagent_type: "general-purpose",
578
578
  description: "Explore frontend layer",
579
579
  run_in_background: true,
580
580
  prompt: `Read the implementation in src/ui/.
@@ -587,7 +587,7 @@ Return:
587
587
  5. file paths worth storing in memory`
588
588
  })
589
589
  Task({
590
- subagent_type: "explore",
590
+ subagent_type: "general-purpose",
591
591
  description: "Explore shared systems",
592
592
  run_in_background: true,
593
593
  prompt: `Read the implementation in src/shared/.
@@ -33,8 +33,8 @@ This skill enables you to send messages to other agents on the same Letta server
33
33
  **Need local access?** If you need the target agent to access your local environment (read/write files, run commands), use the Task tool instead to deploy them as a subagent:
34
34
  ```typescript
35
35
  Task({
36
- agent_id: "agent-xxx", // Deploy this existing agent
37
- subagent_type: "explore", // "explore" = read-only, "general-purpose" = read-write
36
+ agent_id: "agent-xxx", // Deploy this existing agent
37
+ subagent_type: "general-purpose", // read-write access to your local tools
38
38
  prompt: "Look at the code in src/ and tell me about the architecture"
39
39
  })
40
40
  ```
@@ -0,0 +1,270 @@
1
+ ---
2
+ name: "modifying-letta-code"
3
+ description: "Modify your own Letta Code harness: permission rules, hooks, and agent configuration (model, context window, name, toolset, system prompt). Use when you want to change your own deterministic configuration, not your memory."
4
+ ---
5
+
6
+ # Modifying Letta Code (Self-Configuration)
7
+
8
+ This skill tells you — the agent — how to modify your own **harness**: the deterministic configuration layer around you. Load this skill when you want to change how you run (model, permissions, hooks, toolset, system prompt, name, etc.).
9
+
10
+ ## Memory vs Harness
11
+
12
+ Before you change anything, know which layer you're in:
13
+
14
+ | Layer | What it is | How you change it |
15
+ |-------|-----------|-------------------|
16
+ | **Memory** | Dynamic state you learn and reorganize (`$MEMORY_DIR`, memfs, conversation history) | Memory tool, file edits in `$MEMORY_DIR`, skill operations |
17
+ | **Harness** | Deterministic config (model, permissions, hooks, toolset, system prompt) | This skill — edit `settings.json` or call the Letta API |
18
+
19
+ Memory is probabilistic: your notes evolve, your history compacts, your skills get loaded and unloaded. The harness is deterministic: given the same settings, you behave the same way. Don't conflate them — edit memory when you're learning, edit the harness when you're reconfiguring.
20
+
21
+ ## Where to make changes
22
+
23
+ You have two places to modify harness config:
24
+
25
+ ### 1. Settings JSON files (you can edit these directly with Write/Edit)
26
+
27
+ | File | Scope | Contents |
28
+ |------|-------|----------|
29
+ | `~/.letta/settings.json` | User (global) | Permissions, hooks, per-agent settings (`agents[]`), pinning, env vars |
30
+ | `./.letta/settings.json` | Project | Permissions, hooks, shared with team via git |
31
+ | `./.letta/settings.local.json` | Local | Permissions, hooks, personal overrides (gitignored) |
32
+
33
+ Precedence (highest wins): **local > project > user**.
34
+
35
+ ### 2. The Letta API (for server-side agent state)
36
+
37
+ Your **name**, **description**, **model**, **context window**, and **system prompt** live on the Letta server. To change them, call the Letta API.
38
+
39
+ **Base URL:** `https://api.letta.com`
40
+ **Docs:** https://docs.letta.com/api-overview/introduction
41
+ **Auth:** `Authorization: Bearer $LETTA_API_KEY`
42
+
43
+ Your own agent ID is `$LETTA_AGENT_ID` (always available in your environment).
44
+
45
+ You can use the Python or TypeScript SDK, or just `curl`:
46
+
47
+ ```bash
48
+ # Rename yourself
49
+ curl -X PATCH "https://api.letta.com/v1/agents/$LETTA_AGENT_ID" \
50
+ -H "Authorization: Bearer $LETTA_API_KEY" \
51
+ -H "Content-Type: application/json" \
52
+ -d '{"name": "new-name"}'
53
+ ```
54
+
55
+ If you need rich SDK examples, load the `letta-api-client` skill.
56
+
57
+ ---
58
+
59
+ ## 1. Changing your permissions
60
+
61
+ Permissions control which tool calls need user approval. Edit `settings.json` directly, or use the helper script.
62
+
63
+ ### Rule syntax
64
+
65
+ - **Bash** (prefix match with `:*`): `Bash(npm install:*)`, `Bash(git:*)`, `Bash(curl:*)`
66
+ - **Files** (glob): `Read(src/**)`, `Edit(**/*.ts)`, `Write(*.md)`
67
+ - **All** (dangerous): `*`, `Bash`, `Read`
68
+
69
+ ### Helper: add a rule
70
+
71
+ ```bash
72
+ python3 <skill-dir>/scripts/add_permission.py \
73
+ --rule "Bash(curl:*)" \
74
+ --type allow \
75
+ --scope user
76
+ ```
77
+
78
+ ### Direct edit (in `settings.json`)
79
+
80
+ ```json
81
+ {
82
+ "permissions": {
83
+ "allow": ["Bash(npm:*)", "Read(src/**)"],
84
+ "deny": ["Bash(rm -rf:*)"],
85
+ "ask": []
86
+ }
87
+ }
88
+ ```
89
+
90
+ After editing, your new rules apply on your next restart. In-session additions via the approval UI go into session-only memory and are cleared on exit.
91
+
92
+ ---
93
+
94
+ ## 2. Adding hooks
95
+
96
+ Hooks let you run a shell command or LLM prompt in response to events. Use them to log activity, enforce policy, auto-format, or gate actions.
97
+
98
+ ### Events
99
+
100
+ **Tool events** (need a `matcher`):
101
+ - `PreToolUse` — before a tool runs (can block)
102
+ - `PostToolUse` — after a tool succeeds
103
+ - `PostToolUseFailure` — after a tool fails (stderr fed back to you)
104
+ - `PermissionRequest` — when a permission dialog shows (can allow/deny)
105
+
106
+ **Simple events** (no matcher):
107
+ - `UserPromptSubmit` — user sends a prompt (can block)
108
+ - `Stop` — you finish responding (can block)
109
+ - `SubagentStop` — a subagent finishes
110
+ - `PreCompact` — before context compaction
111
+ - `SessionStart`, `SessionEnd`, `Notification`
112
+
113
+ ### Hook types
114
+
115
+ **Command** — runs a shell command:
116
+ ```json
117
+ {"type": "command", "command": "echo $TOOL_INPUT >> ~/audit.log", "timeout": 60000}
118
+ ```
119
+
120
+ **Prompt** — sends event JSON to an LLM for evaluation:
121
+ ```json
122
+ {"type": "prompt", "prompt": "Is this safe? Input: $ARGUMENTS", "model": "gpt-5.2"}
123
+ ```
124
+ Supported events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `UserPromptSubmit`, `Stop`, `SubagentStop`.
125
+
126
+ ### Helper: add a hook
127
+
128
+ ```bash
129
+ python3 <skill-dir>/scripts/add_hook.py \
130
+ --event PreToolUse \
131
+ --matcher Bash \
132
+ --type command \
133
+ --command 'echo "bash: $TOOL_INPUT" >> ~/.letta/audit.log' \
134
+ --scope user
135
+ ```
136
+
137
+ ### Direct edit (in `settings.json`)
138
+
139
+ ```json
140
+ {
141
+ "hooks": {
142
+ "PreToolUse": [
143
+ {
144
+ "matcher": "Bash",
145
+ "hooks": [{"type": "command", "command": "echo $TOOL_INPUT >> audit.log"}]
146
+ }
147
+ ],
148
+ "Stop": [
149
+ {"hooks": [{"type": "command", "command": "say done"}]}
150
+ ]
151
+ }
152
+ }
153
+ ```
154
+
155
+ Matchers: exact (`"Bash"`), multiple (`"Edit|Write"`), all (`"*"`).
156
+
157
+ ---
158
+
159
+ ## 3. Changing your agent configuration
160
+
161
+ Agent config splits between the Letta server and local settings.
162
+
163
+ ### Server-side fields (use the Letta API)
164
+
165
+ Use `PATCH /v1/agents/{agent_id}` with `$LETTA_AGENT_ID`.
166
+
167
+ **Change your model and context window:**
168
+ ```bash
169
+ curl -X PATCH "https://api.letta.com/v1/agents/$LETTA_AGENT_ID" \
170
+ -H "Authorization: Bearer $LETTA_API_KEY" \
171
+ -H "Content-Type: application/json" \
172
+ -d '{
173
+ "llm_config": {
174
+ "model": "claude-sonnet-4.5",
175
+ "model_endpoint_type": "anthropic",
176
+ "context_window": 200000
177
+ }
178
+ }'
179
+ ```
180
+
181
+ **Rename yourself:**
182
+ ```bash
183
+ curl -X PATCH "https://api.letta.com/v1/agents/$LETTA_AGENT_ID" \
184
+ -H "Authorization: Bearer $LETTA_API_KEY" \
185
+ -H "Content-Type: application/json" \
186
+ -d '{"name": "draft-v2"}'
187
+ ```
188
+
189
+ **Update your description:**
190
+ ```bash
191
+ curl -X PATCH "https://api.letta.com/v1/agents/$LETTA_AGENT_ID" \
192
+ -H "Authorization: Bearer $LETTA_API_KEY" \
193
+ -H "Content-Type: application/json" \
194
+ -d '{"description": "..."}'
195
+ ```
196
+
197
+ **Update your system prompt (use with care — system prompt is structural):**
198
+ ```bash
199
+ curl -X PATCH "https://api.letta.com/v1/agents/$LETTA_AGENT_ID" \
200
+ -H "Authorization: Bearer $LETTA_API_KEY" \
201
+ -H "Content-Type: application/json" \
202
+ -d '{"system": "You are..."}'
203
+ ```
204
+
205
+ For Python / TypeScript SDK usage, see `docs.letta.com/api-overview/introduction` or load the `letta-api-client` skill.
206
+
207
+ ### Local per-agent harness (edit `~/.letta/settings.json`)
208
+
209
+ The `agents[]` array stores per-agent harness preferences you can edit directly:
210
+
211
+ ```json
212
+ {
213
+ "agents": [
214
+ {
215
+ "agentId": "agent-abc123",
216
+ "baseUrl": "https://api.letta.com",
217
+ "pinned": true,
218
+ "memfs": { "enabled": true },
219
+ "toolset": "full",
220
+ "systemPromptPreset": "letta-code-v2"
221
+ }
222
+ ]
223
+ }
224
+ ```
225
+
226
+ - **`toolset`** — which tool set to load for this agent
227
+ - **`memfs.enabled`** — whether the memory filesystem is active
228
+ - **`systemPromptPreset`** — which preset was last applied (informational; the actual system prompt is server-side)
229
+ - **`pinned`** — show in the quick-switch list
230
+
231
+ Find your own entry by matching `agentId === $LETTA_AGENT_ID`, then edit the fields you need.
232
+
233
+ ---
234
+
235
+ ## Quick reference: what you want to change
236
+
237
+ | Change | What to do |
238
+ |--------|-----------|
239
+ | Auto-approve `curl` commands | `add_permission.py --rule "Bash(curl:*)" --type allow --scope user` |
240
+ | Block all `rm -rf` | Add `"Bash(rm -rf:*)"` to `permissions.deny` in `settings.json` |
241
+ | Log every Bash command | `add_hook.py --event PreToolUse --matcher Bash --type command --command '...' --scope user` |
242
+ | Auto-format after edits | `add_hook.py --event PostToolUse --matcher "Edit\|Write" --type command --command 'prettier ...' --scope project` |
243
+ | Gate edits with an LLM check | `add_hook.py --event PreToolUse --matcher Edit --type prompt --prompt '...' --scope user` |
244
+ | Change your model | `PATCH /v1/agents/$LETTA_AGENT_ID` with `llm_config.model` |
245
+ | Change your context window | `PATCH /v1/agents/$LETTA_AGENT_ID` with `llm_config.context_window` |
246
+ | Rename yourself | `PATCH /v1/agents/$LETTA_AGENT_ID` with `name` |
247
+ | Update your description | `PATCH /v1/agents/$LETTA_AGENT_ID` with `description` |
248
+ | Modify your system prompt | `PATCH /v1/agents/$LETTA_AGENT_ID` with `system` |
249
+ | Pin yourself for quick-switch | Add `agentId` to `pinnedAgents` in `~/.letta/settings.json` |
250
+ | Change toolset | Edit `agents[].toolset` in `~/.letta/settings.json` |
251
+ | Disable memfs | Edit `agents[].memfs.enabled = false` in `~/.letta/settings.json` (and update system prompt via API if needed) |
252
+ | See what's currently set | `python3 <skill-dir>/scripts/show_config.py` |
253
+
254
+ ---
255
+
256
+ ## After making changes
257
+
258
+ - **`settings.json` changes** — take effect on next session restart. Your current session keeps the old values.
259
+ - **Letta API changes** — apply immediately at the server level, but the in-memory agent config held by your current session may not reflect them until next restart.
260
+ - **System prompt / model changes** — always start a fresh conversation after to get a clean context with the new config.
261
+
262
+ ## Helper scripts in this skill
263
+
264
+ | Script | Purpose |
265
+ |--------|---------|
266
+ | `scripts/add_permission.py` | Add an allow/deny/ask rule to any scope |
267
+ | `scripts/add_hook.py` | Add a command or prompt hook to any event |
268
+ | `scripts/show_config.py` | Show merged permissions, hooks, and per-agent settings across all scopes |
269
+
270
+ All three accept `--scope user|project|local`. Run `--help` for full usage.
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Add a hook to Letta Code settings.
4
+
5
+ Examples:
6
+ # Command hook for Bash tool calls
7
+ python3 add_hook.py --event PreToolUse --matcher Bash \
8
+ --type command --command 'echo "$TOOL_INPUT" >> audit.log' \
9
+ --scope user
10
+
11
+ # Prompt hook for pre-edit safety check
12
+ python3 add_hook.py --event PreToolUse --matcher "Edit|Write" \
13
+ --type prompt --prompt 'Is this safe? Input: $ARGUMENTS' \
14
+ --model gpt-5.2 --scope project
15
+
16
+ # Simple event hook (no matcher needed)
17
+ python3 add_hook.py --event Stop \
18
+ --type command --command 'say done' \
19
+ --scope user
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ TOOL_EVENTS = {"PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest"}
29
+ SIMPLE_EVENTS = {
30
+ "UserPromptSubmit",
31
+ "Notification",
32
+ "Stop",
33
+ "SubagentStop",
34
+ "PreCompact",
35
+ "SessionStart",
36
+ "SessionEnd",
37
+ }
38
+ ALL_EVENTS = TOOL_EVENTS | SIMPLE_EVENTS
39
+
40
+ PROMPT_SUPPORTED = {
41
+ "PreToolUse",
42
+ "PostToolUse",
43
+ "PostToolUseFailure",
44
+ "PermissionRequest",
45
+ "UserPromptSubmit",
46
+ "Stop",
47
+ "SubagentStop",
48
+ }
49
+
50
+
51
+ def get_settings_path(scope: str, working_directory: str) -> Path:
52
+ if scope == "user":
53
+ return Path.home() / ".letta" / "settings.json"
54
+ elif scope == "project":
55
+ return Path(working_directory) / ".letta" / "settings.json"
56
+ elif scope == "local":
57
+ return Path(working_directory) / ".letta" / "settings.local.json"
58
+ else:
59
+ raise ValueError(f"Unknown scope: {scope}")
60
+
61
+
62
+ def load_settings(path: Path) -> dict:
63
+ if path.exists():
64
+ try:
65
+ with open(path) as f:
66
+ return json.load(f)
67
+ except json.JSONDecodeError:
68
+ print(f"Warning: Could not parse {path}, starting fresh", file=sys.stderr)
69
+ return {}
70
+ return {}
71
+
72
+
73
+ def save_settings(path: Path, settings: dict) -> None:
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+ with open(path, "w") as f:
76
+ json.dump(settings, f, indent=2)
77
+ print(f"Saved to {path}")
78
+
79
+
80
+ def build_hook_config(args) -> dict:
81
+ """Build the individual hook config from args."""
82
+ hook: dict = {"type": args.type}
83
+
84
+ if args.type == "command":
85
+ if not args.command:
86
+ raise ValueError("--command is required for type=command")
87
+ hook["command"] = args.command
88
+ elif args.type == "prompt":
89
+ if not args.prompt:
90
+ raise ValueError("--prompt is required for type=prompt")
91
+ if args.event not in PROMPT_SUPPORTED:
92
+ raise ValueError(
93
+ f"Event {args.event!r} does not support prompt hooks. "
94
+ f"Supported: {sorted(PROMPT_SUPPORTED)}"
95
+ )
96
+ hook["prompt"] = args.prompt
97
+ if args.model:
98
+ hook["model"] = args.model
99
+
100
+ if args.timeout is not None:
101
+ hook["timeout"] = args.timeout
102
+
103
+ return hook
104
+
105
+
106
+ def add_hook(settings: dict, args) -> None:
107
+ """Add a hook entry to the settings dict."""
108
+ if "hooks" not in settings:
109
+ settings["hooks"] = {}
110
+
111
+ hooks_config = settings["hooks"]
112
+ event = args.event
113
+
114
+ if event not in hooks_config:
115
+ hooks_config[event] = []
116
+
117
+ hook = build_hook_config(args)
118
+
119
+ if event in TOOL_EVENTS:
120
+ # Tool events: need a matcher
121
+ matcher = args.matcher or "*"
122
+ # Find existing matcher group or create new one
123
+ entry = next(
124
+ (e for e in hooks_config[event] if e.get("matcher") == matcher), None
125
+ )
126
+ if entry is None:
127
+ entry = {"matcher": matcher, "hooks": []}
128
+ hooks_config[event].append(entry)
129
+ entry["hooks"].append(hook)
130
+ else:
131
+ # Simple events: no matcher, just hooks
132
+ if hooks_config[event]:
133
+ # Append to existing group
134
+ hooks_config[event][0].setdefault("hooks", []).append(hook)
135
+ else:
136
+ hooks_config[event].append({"hooks": [hook]})
137
+
138
+
139
+ def ensure_local_gitignored(working_directory: str) -> None:
140
+ gitignore_path = Path(working_directory) / ".gitignore"
141
+ pattern = ".letta/settings.local.json"
142
+ try:
143
+ content = gitignore_path.read_text() if gitignore_path.exists() else ""
144
+ if pattern not in content:
145
+ with open(gitignore_path, "a") as f:
146
+ if content and not content.endswith("\n"):
147
+ f.write("\n")
148
+ f.write(f"{pattern}\n")
149
+ print(f"Added {pattern} to .gitignore")
150
+ except Exception as e:
151
+ print(f"Warning: Could not update .gitignore: {e}", file=sys.stderr)
152
+
153
+
154
+ def main():
155
+ parser = argparse.ArgumentParser(
156
+ description="Add a hook to Letta Code settings",
157
+ formatter_class=argparse.RawDescriptionHelpFormatter,
158
+ epilog=__doc__,
159
+ )
160
+ parser.add_argument(
161
+ "--event",
162
+ required=True,
163
+ choices=sorted(ALL_EVENTS),
164
+ help="Hook event name",
165
+ )
166
+ parser.add_argument(
167
+ "--matcher",
168
+ help="Tool matcher pattern (for tool events). Examples: 'Bash', 'Edit|Write', '*'",
169
+ )
170
+ parser.add_argument(
171
+ "--type",
172
+ required=True,
173
+ choices=["command", "prompt"],
174
+ help="Hook type",
175
+ )
176
+ parser.add_argument("--command", help="Shell command (for type=command)")
177
+ parser.add_argument(
178
+ "--prompt",
179
+ help="LLM prompt text (for type=prompt). Use $ARGUMENTS for hook input JSON.",
180
+ )
181
+ parser.add_argument("--model", help="LLM model (for type=prompt)")
182
+ parser.add_argument(
183
+ "--timeout", type=int, help="Timeout in milliseconds (default: 60000/30000)"
184
+ )
185
+ parser.add_argument(
186
+ "--scope",
187
+ required=True,
188
+ choices=["user", "project", "local"],
189
+ help="Where to save the hook",
190
+ )
191
+ parser.add_argument(
192
+ "--cwd",
193
+ default=os.getcwd(),
194
+ help="Working directory for project/local scope (default: cwd)",
195
+ )
196
+
197
+ args = parser.parse_args()
198
+
199
+ # Validation
200
+ if args.event in TOOL_EVENTS and not args.matcher:
201
+ print(
202
+ f"Warning: {args.event} is a tool event; using matcher='*' (match all tools)",
203
+ file=sys.stderr,
204
+ )
205
+
206
+ settings_path = get_settings_path(args.scope, args.cwd)
207
+ settings = load_settings(settings_path)
208
+
209
+ try:
210
+ add_hook(settings, args)
211
+ except ValueError as e:
212
+ print(f"Error: {e}", file=sys.stderr)
213
+ sys.exit(1)
214
+
215
+ save_settings(settings_path, settings)
216
+ print(f"Added {args.type} hook on {args.event}")
217
+
218
+ if args.scope == "local":
219
+ ensure_local_gitignored(args.cwd)
220
+
221
+
222
+ if __name__ == "__main__":
223
+ main()
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Add a permission rule to Letta Code settings.
4
+
5
+ Usage:
6
+ python3 add_permission.py --rule "Bash(npm run:*)" --type allow --scope user
7
+ python3 add_permission.py --rule "Read(src/**)" --type allow --scope project
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ def get_settings_path(scope: str, working_directory: str) -> Path:
18
+ """Get the settings file path for a given scope."""
19
+ if scope == "user":
20
+ return Path.home() / ".letta" / "settings.json"
21
+ elif scope == "project":
22
+ return Path(working_directory) / ".letta" / "settings.json"
23
+ elif scope == "local":
24
+ return Path(working_directory) / ".letta" / "settings.local.json"
25
+ else:
26
+ raise ValueError(f"Unknown scope: {scope}")
27
+
28
+
29
+ def load_settings(path: Path) -> dict:
30
+ """Load settings from a JSON file, or return empty dict if not found."""
31
+ if path.exists():
32
+ try:
33
+ with open(path) as f:
34
+ return json.load(f)
35
+ except json.JSONDecodeError:
36
+ print(f"Warning: Could not parse {path}, starting fresh", file=sys.stderr)
37
+ return {}
38
+ return {}
39
+
40
+
41
+ def save_settings(path: Path, settings: dict) -> None:
42
+ """Save settings to a JSON file, creating parent directories if needed."""
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ with open(path, "w") as f:
45
+ json.dump(settings, f, indent=2)
46
+ print(f"Saved to {path}")
47
+
48
+
49
+ def add_rule(settings: dict, rule: str, rule_type: str) -> bool:
50
+ """
51
+ Add a permission rule to settings.
52
+
53
+ Returns True if the rule was added, False if it already exists.
54
+ """
55
+ if "permissions" not in settings:
56
+ settings["permissions"] = {}
57
+
58
+ if rule_type not in settings["permissions"]:
59
+ settings["permissions"][rule_type] = []
60
+
61
+ rules = settings["permissions"][rule_type]
62
+
63
+ if rule in rules:
64
+ return False
65
+
66
+ rules.append(rule)
67
+ return True
68
+
69
+
70
+ def ensure_local_gitignored(working_directory: str) -> None:
71
+ """Ensure .letta/settings.local.json is in .gitignore."""
72
+ gitignore_path = Path(working_directory) / ".gitignore"
73
+ pattern = ".letta/settings.local.json"
74
+
75
+ try:
76
+ content = ""
77
+ if gitignore_path.exists():
78
+ content = gitignore_path.read_text()
79
+
80
+ if pattern not in content:
81
+ with open(gitignore_path, "a") as f:
82
+ if content and not content.endswith("\n"):
83
+ f.write("\n")
84
+ f.write(f"{pattern}\n")
85
+ print(f"Added {pattern} to .gitignore")
86
+ except Exception as e:
87
+ print(f"Warning: Could not update .gitignore: {e}", file=sys.stderr)
88
+
89
+
90
+ def main():
91
+ parser = argparse.ArgumentParser(
92
+ description="Add a permission rule to Letta Code settings"
93
+ )
94
+ parser.add_argument(
95
+ "--rule",
96
+ required=True,
97
+ help='Permission rule pattern, e.g., "Bash(npm run:*)" or "Read(src/**)"',
98
+ )
99
+ parser.add_argument(
100
+ "--type",
101
+ required=True,
102
+ choices=["allow", "deny", "ask"],
103
+ help="Type of permission rule",
104
+ )
105
+ parser.add_argument(
106
+ "--scope",
107
+ required=True,
108
+ choices=["user", "project", "local"],
109
+ help="Where to save the rule",
110
+ )
111
+ parser.add_argument(
112
+ "--cwd",
113
+ default=os.getcwd(),
114
+ help="Working directory for project/local scope (default: current directory)",
115
+ )
116
+
117
+ args = parser.parse_args()
118
+
119
+ settings_path = get_settings_path(args.scope, args.cwd)
120
+ settings = load_settings(settings_path)
121
+
122
+ if add_rule(settings, args.rule, args.type):
123
+ save_settings(settings_path, settings)
124
+ print(f"Added {args.type} rule: {args.rule}")
125
+
126
+ # Ensure local settings are gitignored
127
+ if args.scope == "local":
128
+ ensure_local_gitignored(args.cwd)
129
+ else:
130
+ print(f"Rule already exists: {args.rule}")
131
+ sys.exit(0)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()