chaotic-cli 0.1.0a1__py3-none-any.whl

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.
cli/config.py ADDED
@@ -0,0 +1,475 @@
1
+ """CLI configuration management.
2
+
3
+ Config is layered:
4
+ 1. Local .chaotic file (found by walking up from cwd, like git)
5
+ 2. Global ~/.chaotic/config.json (shared auth, defaults)
6
+ 3. Profile-specific ~/.chaotic/{profile}.json (for multi-agent setups)
7
+
8
+ Local config overrides global for team/project settings.
9
+ Auth (token, api_key) is stored globally but can be overridden locally.
10
+
11
+ Profile support:
12
+ - Set CHAOTIC_PROFILE env var or use --profile flag
13
+ - Profile config is loaded instead of config.json
14
+ - Useful for multiple agents (claude, codex, etc.) on same workstation
15
+ """
16
+ import json
17
+ import os
18
+ import subprocess
19
+ from pathlib import Path
20
+
21
+
22
+ # Module-level profile setting (set by CLI --profile flag)
23
+ _current_profile: str | None = None
24
+
25
+
26
+ def set_profile(profile: str | None):
27
+ """Set the current profile (called by CLI --profile flag)."""
28
+ global _current_profile
29
+ _current_profile = profile
30
+
31
+
32
+ def get_profile() -> str | None:
33
+ """Get current profile from module state or environment."""
34
+ return _current_profile or os.environ.get("CHAOTIC_PROFILE")
35
+
36
+
37
+ def get_chaotic_home() -> Path:
38
+ """Get the Chaotic home directory.
39
+
40
+ Respects CHAOTIC_HOME env var for testing/alternate installs.
41
+ """
42
+ if home := os.environ.get("CHAOTIC_HOME"):
43
+ return Path(home)
44
+ return Path.home() / ".chaotic"
45
+
46
+
47
+ GLOBAL_CONFIG_DIR = get_chaotic_home()
48
+ LOCAL_CONFIG_NAME = ".chaotic"
49
+
50
+
51
+ class ProfileError(ValueError):
52
+ """Invalid profile name or path."""
53
+ pass
54
+
55
+
56
+ def get_global_config_file() -> Path:
57
+ """Get the global config file path, respecting profile setting.
58
+
59
+ If a profile is set (via --profile or CHAOTIC_PROFILE), uses
60
+ ~/.chaotic/{profile}.json instead of ~/.chaotic/config.json.
61
+
62
+ Profile names must be simple identifiers (alphanumeric, hyphen, underscore).
63
+ Path traversal attempts are rejected for security.
64
+
65
+ Raises:
66
+ ProfileError: If profile name contains invalid characters or attempts
67
+ path traversal.
68
+ """
69
+ profile = get_profile()
70
+ if not profile:
71
+ return GLOBAL_CONFIG_DIR / "config.json"
72
+
73
+ # Security: reject path traversal and special characters
74
+ # Only allow simple profile names: alphanumeric, hyphen, underscore
75
+ if "/" in profile or "\\" in profile:
76
+ raise ProfileError(f"Invalid profile name '{profile}': use simple names like 'claude', not paths")
77
+ if ".." in profile:
78
+ raise ProfileError(f"Invalid profile name '{profile}': path traversal not allowed")
79
+ if profile.startswith("."):
80
+ raise ProfileError(f"Invalid profile name '{profile}': cannot start with '.'")
81
+ if not all(c.isalnum() or c in "-_" for c in profile):
82
+ raise ProfileError(f"Invalid profile name '{profile}': use only letters, numbers, hyphens, underscores")
83
+
84
+ config_file = GLOBAL_CONFIG_DIR / f"{profile}.json"
85
+
86
+ # Extra safety: verify resolved path stays within config directory
87
+ try:
88
+ resolved = config_file.resolve()
89
+ chaotic_dir = GLOBAL_CONFIG_DIR.resolve()
90
+ if not str(resolved).startswith(str(chaotic_dir) + os.sep):
91
+ raise ProfileError(f"Profile path escapes config directory: {resolved}")
92
+ except OSError as e:
93
+ raise ProfileError(f"Cannot resolve profile path: {e}")
94
+
95
+ return config_file
96
+
97
+
98
+ # Keep for backwards compatibility
99
+ GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.json"
100
+
101
+ # Default port: CHAOS on phone keypad (24267)
102
+ DEFAULT_PORT = 24267
103
+
104
+
105
+ def find_local_config() -> Path | None:
106
+ """Walk up directory tree to find .chaotic config file (like git).
107
+
108
+ Returns the Path to .chaotic if found, None otherwise.
109
+ Stops at home directory or git root to prevent config injection attacks.
110
+ """
111
+ current = Path.cwd().resolve()
112
+ home = Path.home().resolve()
113
+
114
+ while True:
115
+ try:
116
+ config_path = current / LOCAL_CONFIG_NAME
117
+ if config_path.exists() and config_path.is_file():
118
+ return config_path
119
+ except (PermissionError, OSError):
120
+ pass # Skip inaccessible directories
121
+
122
+ # Stop at home directory to prevent config injection from parent dirs
123
+ if current == home:
124
+ break
125
+
126
+ # Stop at git root (if .git exists, don't look further up)
127
+ try:
128
+ if (current / ".git").exists():
129
+ break
130
+ except (PermissionError, OSError):
131
+ pass
132
+
133
+ # Stop at filesystem root
134
+ parent = current.parent
135
+ if parent == current:
136
+ break
137
+ current = parent
138
+
139
+ return None
140
+
141
+
142
+ def get_git_root() -> Path | None:
143
+ """Get the root directory of the current git repository.
144
+
145
+ Returns the Path to git root if in a git repo, None otherwise.
146
+ """
147
+ try:
148
+ result = subprocess.run(
149
+ ["git", "rev-parse", "--show-toplevel"],
150
+ capture_output=True,
151
+ text=True,
152
+ check=True
153
+ )
154
+ git_root = result.stdout.strip()
155
+ if git_root:
156
+ return Path(git_root)
157
+ return None
158
+ except (subprocess.CalledProcessError, FileNotFoundError):
159
+ return None
160
+
161
+
162
+ def get_local_config_path() -> Path:
163
+ """Get the path where local config should be saved.
164
+
165
+ Priority:
166
+ 1. Existing .chaotic found by walking up
167
+ 2. Git repo root (if in a git repo)
168
+ 3. Current working directory
169
+ """
170
+ # First check if we already have a config file
171
+ existing = find_local_config()
172
+ if existing:
173
+ return existing
174
+
175
+ # Otherwise, prefer git root, fall back to cwd
176
+ git_root = get_git_root()
177
+ if git_root:
178
+ return git_root / LOCAL_CONFIG_NAME
179
+
180
+ return Path.cwd() / LOCAL_CONFIG_NAME
181
+
182
+
183
+ def ensure_global_config_dir():
184
+ """Ensure global config directory exists."""
185
+ GLOBAL_CONFIG_DIR.mkdir(exist_ok=True)
186
+
187
+
188
+ def load_global_config() -> dict:
189
+ """Load global configuration.
190
+
191
+ Respects profile setting - if a profile is active, loads from
192
+ ~/.chaotic/{profile}.json instead of ~/.chaotic/config.json.
193
+
194
+ Raises:
195
+ ProfileError: If profile name is invalid.
196
+ RuntimeError: If config file exists but cannot be read or parsed.
197
+ """
198
+ ensure_global_config_dir()
199
+ config_file = get_global_config_file() # May raise ProfileError
200
+ if config_file.exists():
201
+ try:
202
+ with open(config_file) as f:
203
+ return json.load(f)
204
+ except json.JSONDecodeError as e:
205
+ raise RuntimeError(f"Invalid JSON in {config_file}: {e}")
206
+ except PermissionError:
207
+ raise RuntimeError(f"Permission denied reading {config_file}")
208
+ except IsADirectoryError:
209
+ raise RuntimeError(f"Expected file but found directory: {config_file}")
210
+ except OSError as e:
211
+ raise RuntimeError(f"Cannot read config file {config_file}: {e}")
212
+ return {}
213
+
214
+
215
+ def save_global_config(config: dict):
216
+ """Save global configuration.
217
+
218
+ Respects profile setting - if a profile is active, saves to
219
+ ~/.chaotic/{profile}.json instead of ~/.chaotic/config.json.
220
+ """
221
+ ensure_global_config_dir()
222
+ config_file = get_global_config_file()
223
+ with open(config_file, "w") as f:
224
+ json.dump(config, f, indent=2)
225
+ # Restrict permissions - config may contain API keys
226
+ os.chmod(config_file, 0o600)
227
+
228
+
229
+ def load_local_config() -> dict:
230
+ """Load local (project) configuration by walking up directory tree."""
231
+ config_path = find_local_config()
232
+ if config_path:
233
+ try:
234
+ with open(config_path) as f:
235
+ return json.load(f)
236
+ except json.JSONDecodeError as e:
237
+ raise RuntimeError(f"Invalid JSON in {config_path}: {e}")
238
+ return {}
239
+
240
+
241
+ def save_local_config(config: dict):
242
+ """Save local (project) configuration.
243
+
244
+ Saves to:
245
+ 1. Existing .chaotic found by walking up
246
+ 2. Git repo root (if in a git repo)
247
+ 3. Current working directory
248
+ """
249
+ config_path = get_local_config_path()
250
+ if config_path.exists() and config_path.is_dir():
251
+ raise RuntimeError(f"Cannot save config: {config_path} is a directory. Remove it first.")
252
+ with open(config_path, "w") as f:
253
+ json.dump(config, f, indent=2)
254
+ # Restrict permissions - config may contain API keys
255
+ os.chmod(config_path, 0o600)
256
+
257
+
258
+ def load_config() -> dict:
259
+ """Load merged configuration (local overrides global)."""
260
+ global_conf = load_global_config()
261
+ local_conf = load_local_config()
262
+ # Merge: local overrides global
263
+ return {**global_conf, **local_conf}
264
+
265
+
266
+ def save_config(config: dict):
267
+ """Save configuration (to local file for project settings)."""
268
+ # For backward compatibility, save to local if it exists, else global
269
+ if has_local_config():
270
+ save_local_config(config)
271
+ else:
272
+ save_global_config(config)
273
+
274
+
275
+ def get_api_url() -> str:
276
+ """Get API URL from config or environment."""
277
+ config = load_config()
278
+ return os.environ.get("CHAOTIC_API_URL") or config.get("api_url", f"http://localhost:{DEFAULT_PORT}/api")
279
+
280
+
281
+ def set_api_url(url: str, local: bool = False):
282
+ """Set API URL in config."""
283
+ if local:
284
+ config = load_local_config()
285
+ config["api_url"] = url
286
+ save_local_config(config)
287
+ else:
288
+ config = load_global_config()
289
+ config["api_url"] = url
290
+ save_global_config(config)
291
+
292
+
293
+ def get_token() -> str | None:
294
+ """Get authentication token."""
295
+ config = load_config()
296
+ return os.environ.get("CHAOTIC_TOKEN") or config.get("token")
297
+
298
+
299
+ def set_token(token: str | None):
300
+ """Set authentication token (global)."""
301
+ config = load_global_config()
302
+ if token:
303
+ config["token"] = token
304
+ elif "token" in config:
305
+ del config["token"]
306
+ save_global_config(config)
307
+
308
+
309
+ def get_current_team() -> str | None:
310
+ """Get current team ID (prefers local, falls back to global)."""
311
+ config = load_config()
312
+ return config.get("current_team")
313
+
314
+
315
+ def set_current_team(team_id: str | None, local: bool = True):
316
+ """Set current team ID (local by default for per-project config)."""
317
+ if local:
318
+ config = load_local_config()
319
+ if team_id:
320
+ config["current_team"] = team_id
321
+ elif "current_team" in config:
322
+ del config["current_team"]
323
+ save_local_config(config)
324
+ else:
325
+ config = load_global_config()
326
+ if team_id:
327
+ config["current_team"] = team_id
328
+ elif "current_team" in config:
329
+ del config["current_team"]
330
+ save_global_config(config)
331
+
332
+
333
+ def get_current_project() -> str | None:
334
+ """Get current project ID (prefers local, falls back to global)."""
335
+ config = load_config()
336
+ return config.get("current_project")
337
+
338
+
339
+ def set_current_project(project_id: str | None, local: bool = True):
340
+ """Set current project ID (local by default for per-project config)."""
341
+ if local:
342
+ config = load_local_config()
343
+ if project_id:
344
+ config["current_project"] = project_id
345
+ elif "current_project" in config:
346
+ del config["current_project"]
347
+ save_local_config(config)
348
+ else:
349
+ config = load_global_config()
350
+ if project_id:
351
+ config["current_project"] = project_id
352
+ elif "current_project" in config:
353
+ del config["current_project"]
354
+ save_global_config(config)
355
+
356
+
357
+ def get_api_key() -> str | None:
358
+ """Get API key (prefers local, falls back to global)."""
359
+ config = load_config()
360
+ return os.environ.get("CHAOTIC_API_KEY") or config.get("api_key")
361
+
362
+
363
+ def set_api_key(api_key: str | None, local: bool = False):
364
+ """Set API key (global by default, but can be local for per-project keys)."""
365
+ if local:
366
+ config = load_local_config()
367
+ if api_key:
368
+ config["api_key"] = api_key
369
+ elif "api_key" in config:
370
+ del config["api_key"]
371
+ save_local_config(config)
372
+ else:
373
+ config = load_global_config()
374
+ if api_key:
375
+ config["api_key"] = api_key
376
+ elif "api_key" in config:
377
+ del config["api_key"]
378
+ save_global_config(config)
379
+
380
+
381
+ def get_web_url() -> str:
382
+ """Get web URL (frontend) by deriving from API URL."""
383
+ api_url = get_api_url()
384
+ # Strip /api suffix to get frontend URL
385
+ if api_url.endswith("/api"):
386
+ return api_url[:-4]
387
+ return api_url.rstrip("/")
388
+
389
+
390
+ def has_local_config() -> bool:
391
+ """Check if local .chaotic config file exists (walks up directory tree)."""
392
+ return find_local_config() is not None
393
+
394
+
395
+ def list_profiles() -> list[str]:
396
+ """List all available profiles in ~/.chaotic/.
397
+
398
+ Returns profile names (without .json extension), excluding 'config'
399
+ which is the default profile.
400
+
401
+ Symlinks are ignored for security (to prevent path traversal attacks).
402
+ Permission errors are handled gracefully by returning an empty list.
403
+ """
404
+ profiles = []
405
+ config_dir = get_chaotic_home()
406
+ if config_dir.exists():
407
+ try:
408
+ for f in config_dir.iterdir():
409
+ # Security: skip symlinks to prevent path traversal
410
+ if f.is_symlink():
411
+ continue
412
+ if f.is_file() and f.suffix == ".json":
413
+ name = f.stem
414
+ # Include 'config' as 'default' for clarity
415
+ if name == "config":
416
+ profiles.append("default")
417
+ else:
418
+ profiles.append(name)
419
+ except (PermissionError, OSError):
420
+ # Return empty list if we can't read the directory
421
+ return []
422
+ return sorted(profiles)
423
+
424
+
425
+ def get_effective_profile() -> str:
426
+ """Get the effective profile name being used.
427
+
428
+ Returns the profile from --profile/CHAOTIC_PROFILE, auto-selected single
429
+ profile, or 'default' if none set and no profiles exist.
430
+ """
431
+ profile = get_profile()
432
+ return profile if profile else "default"
433
+
434
+
435
+ class ProfileAmbiguityError(Exception):
436
+ """Raised when multiple profiles exist but none is selected."""
437
+
438
+ def __init__(self, profiles: list[str]):
439
+ self.profiles = profiles
440
+ profiles_str = ", ".join(profiles)
441
+ super().__init__(
442
+ f"Multiple profiles found but CHAOTIC_PROFILE not set.\n"
443
+ f"Available profiles: {profiles_str}\n"
444
+ f"Set CHAOTIC_PROFILE=<name> or use --profile <name>"
445
+ )
446
+
447
+
448
+ def check_profile_ambiguity():
449
+ """Check for profile ambiguity and auto-select single profile.
450
+
451
+ If multiple profiles exist in ~/.chaotic/ and no profile is explicitly
452
+ selected (via --profile or CHAOTIC_PROFILE), raises ProfileAmbiguityError.
453
+
454
+ If exactly one profile exists and none is selected, auto-selects it.
455
+
456
+ This enforces "fail closed" behavior to prevent accidentally using the
457
+ wrong identity, especially important for AI agents.
458
+ """
459
+ # If a profile is explicitly set, no ambiguity
460
+ if get_profile():
461
+ return
462
+
463
+ profiles = list_profiles()
464
+
465
+ # No profiles - use default config.json
466
+ if len(profiles) == 0:
467
+ return
468
+
469
+ # Single profile - auto-select it
470
+ if len(profiles) == 1:
471
+ set_profile(profiles[0])
472
+ return
473
+
474
+ # Multiple profiles without explicit selection - fail closed
475
+ raise ProfileAmbiguityError(profiles)