oasr 0.5.2__py3-none-any.whl → 0.6.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oasr
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: CLI for managing agent skills across IDE integrations
5
5
  Project-URL: Homepage, https://github.com/jgodau/asr
6
6
  Project-URL: Repository, https://github.com/jgodau/asr
@@ -210,6 +210,7 @@ Classifier: Topic :: Software Development :: Build Tools
210
210
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
211
211
  Requires-Python: >=3.10
212
212
  Requires-Dist: pyyaml>=6.0
213
+ Requires-Dist: questionary>=2.0.1
213
214
  Requires-Dist: tomli-w>=1.0.0
214
215
  Requires-Dist: tomli>=2.0.0; python_version < '3.11'
215
216
  Provides-Extra: dev
@@ -219,7 +220,7 @@ Description-Content-Type: text/markdown
219
220
 
220
221
  # OASR
221
222
 
222
- **Open Agent Skill Registry** — Manage reusable AI agent skills across IDEs without drift.
223
+ **Open Agent Skill Registry** — Register, sync, and reuse AI agent skills across IDEs with a single source of truth.
223
224
 
224
225
  ---
225
226
 
@@ -229,16 +230,22 @@ You've built useful skills for your AI coding assistant. They work great in Curs
229
230
 
230
231
  Each tool expects skills in different locations with different formats:
231
232
 
232
- - Cursor: `.cursor/skills/`
233
- - Windsurf: `.windsurf/skills/`
233
+ - Cursor: `.cursor/commands/`
234
+ - Windsurf: `.windsurf/workflows/`
234
235
  - Claude: `.claude/commands/`
235
- - Copilot: `.github/.md`
236
+ - Copilot: `.github/prompts/`
236
237
 
237
238
  So you copy your skills everywhere. Then you improve one. Now the copies are stale. You forget which version is current. Some break silently. This is **skill drift**.
238
239
 
239
240
  ## The Solution
240
241
 
241
- ASR keeps your skills in one place and generates thin adapters for each IDE.
242
+ ASR keeps your skills in a registry, syncs local and remote sources, and generates thin adapters for each IDE.
243
+ It also lets you execute skills safely with policy profiles.
244
+
245
+ Key capabilities:
246
+ - Register skills once (local folders or GitHub/GitLab URLs)
247
+ - Sync and track drift across sources
248
+ - Generate IDE adapters and run skills via `oasr exec`
242
249
 
243
250
  ```text
244
251
  ┌─────────────────────────────────────────────────────────┐
@@ -267,12 +274,15 @@ No copying. No drift. One source of truth.
267
274
 
268
275
  ```bash
269
276
  # Register local skills
270
- oasr add ~/skills/git-commit
271
- oasr add ~/skills/code-review
277
+ oasr registry add ~/skills/git-commit
278
+ oasr registry add ~/skills/code-review
279
+
280
+ # List registered skills
281
+ oasr registry list
272
282
 
273
283
  # Register remote skills from GitHub/GitLab
274
- oasr add https://github.com/user/skills-repo/tree/main/my-skill
275
- oasr add https://gitlab.com/org/project/tree/main/cool-skill
284
+ oasr registry add https://github.com/user/skills-repo/tree/main/my-skill
285
+ oasr registry add https://gitlab.com/org/project/tree/main/cool-skill
276
286
 
277
287
  # Generate adapters for a project
278
288
  oasr adapter --output-dir ~/projects/my-app
@@ -294,16 +304,16 @@ ASR supports registering skills directly from GitHub and GitLab repositories:
294
304
 
295
305
  ```bash
296
306
  # Add a skill from GitHub
297
- oasr add https://github.com/user/repo/tree/main/skills/my-skill
307
+ oasr registry add https://github.com/user/repo/tree/main/skills/my-skill
298
308
 
299
309
  # Add a skill from GitLab
300
- oasr add https://gitlab.com/org/project/tree/dev/cool-skill
310
+ oasr registry add https://gitlab.com/org/project/tree/dev/cool-skill
301
311
 
302
312
  # Sync remote skills (check for updates)
303
- oasr sync
313
+ oasr registry sync
304
314
 
305
315
  # Use remote skills
306
- oasr use my-skill -d ./output
316
+ oasr clone my-skill -d ./output
307
317
  ```
308
318
 
309
319
  **Authentication** (optional, for private repos and higher rate limits):
@@ -313,7 +323,7 @@ export GITHUB_TOKEN=ghp_your_token_here
313
323
  export GITLAB_TOKEN=glpat_your_token_here
314
324
  ```
315
325
 
316
- Remote skills are fetched on-demand during `adapter` and `use` operations. The registry stores the URL, and `sync` checks if the remote source has changed.
326
+ Remote skills are fetched on-demand during `adapter` and `clone` operations. The registry stores the URL, and `oasr registry sync` checks if the remote source has changed.
317
327
 
318
328
  ---
319
329
 
@@ -345,7 +355,7 @@ See [`oasr completion --help`](docs/commands/COMPLETION.md) for details.
345
355
 
346
356
  ---
347
357
 
348
- ## Supported `asr adapter` IDEs
358
+ ## Supported `oasr adapter` IDEs
349
359
 
350
360
  | IDE | Adapter | Output |
351
361
  |----------------|------------|-------------------------------|
@@ -367,11 +377,11 @@ See [LICENSE](LICENSE).
367
377
 
368
378
  | Command | Screenshot |
369
379
  |---------|-----------|
370
- | **oasr list** | ![list](docs/.images/list.png) |
371
- | **oasr add** (local) | ![add](docs/.images/add.png) |
372
- | **oasr add** (remote) | ![add-remote](docs/.images/add-remote.png) |
373
- | **oasr sync** | ![sync](docs/.images/sync.png) |
374
- | **oasr status** | ![status](docs/.images/status.png) |
380
+ | **oasr registry list** | ![list](docs/.images/list.png) |
381
+ | **oasr registry add** (local) | ![add](docs/.images/add.png) |
382
+ | **oasr registry add** (remote) | ![add-remote](docs/.images/add-remote.png) |
383
+ | **oasr registry sync** | ![sync](docs/.images/sync.png) |
384
+ | **oasr registry -v** | ![status](docs/.images/status.png) |
375
385
  | **oasr find** | ![find](docs/.images/find.png) |
376
386
  | **oasr adapter** | ![adapter](docs/.images/adapter.png) |
377
387
 
@@ -1,7 +1,7 @@
1
1
  __init__.py,sha256=cYuwXNht5J2GDPEbHz57rmXRyWzaUgAaCXz8okR0rKE,84
2
2
  __main__.py,sha256=Due_Us-4KNlLZhf8MkmoP1hWS5qMWmpZvz2ZaCqPHT4,120
3
3
  adapter.py,sha256=WEpYkKDTb7We0zU9i6Z-r5ydtUdghNhxTZ5Eq58h4fU,10027
4
- cli.py,sha256=LQg93XqUurMvcga2gV6sebv2RXGqfwDNvCN_UOLeTUc,2820
4
+ cli.py,sha256=gJLlOplekDkoFKceG3D5tXZ8m0Jm8tnOu0x9n1y2MW0,2883
5
5
  discovery.py,sha256=WWF8SN2LH88mOUBJLavM7rvXcxi6uDQGpqRK20GysxA,3298
6
6
  manifest.py,sha256=feNCjkFWfhoVubevKjLtKoIEuzT1YGQn6wWgs9XM8_o,12229
7
7
  registry.py,sha256=zGutwVP39xaYqc3KDEXMWCV1tORYpqc5JISO8OaWP1Q,4470
@@ -16,11 +16,11 @@ adapters/copilot.py,sha256=090aS0N7SApSK5kcmm2xErjyUmEsOMrj0n36B0M3fAM,7274
16
16
  adapters/cursor.py,sha256=BoS3Xo_hZyV4zrZKpNjDAaeGmJPTn_j_GI5QXuW5AOo,2244
17
17
  adapters/windsurf.py,sha256=VZidyuFxiHQWl64w0-OgUsfdWeGXV7bf7_XYkiGchyw,2369
18
18
  agents/__init__.py,sha256=Dwdwzb01DM2ZJ5pZBsbLPUYRM4Emai7p3oeayiYcj1w,546
19
- agents/base.py,sha256=ALFkXkKs0IBoZKrEjFoNL_GRa7Wt_DC783UMo-6NOFA,2872
20
- agents/claude.py,sha256=csZ1efZ6GFyaLNHDszKF6hCzJQBiebnx1gmGktpw7DI,676
21
- agents/codex.py,sha256=t7VVVyln_OjbLP1RQzL-x7PJ1C6grVbDCKddI84kre4,674
22
- agents/copilot.py,sha256=kfBk59WXOGd2ZIBBI74kT1WCNH3jFyrOg_HhIkAGG64,697
23
- agents/opencode.py,sha256=fbBAqJhUg0uk6wrhC3SwaE5X27Qej96p9QgVAPpty8w,702
19
+ agents/base.py,sha256=NBBhpJ4KMBLpi8UjGWaKNx9RNJkSxpobDq2LzIa0EYw,3213
20
+ agents/claude.py,sha256=J3LIha5npufT0dD5ZK2eWvM7BZjb615QYHGu-C-pFXI,857
21
+ agents/codex.py,sha256=tYZQEJCLPTxm8zlpwxADyZEbz9EAWmZPrJM3CPRX6lI,853
22
+ agents/copilot.py,sha256=5gieLBlXcpHu6Ci5JZo2QwJoSKeD-Xprb7nCtU3ODXU,878
23
+ agents/opencode.py,sha256=wTUF2_1jaitQkxjs7oRgvpRmdtyOlLBdvLdJRAMdjAw,881
24
24
  agents/registry.py,sha256=i6YUAdNUXq1LNqLDNqSg2RlHxqF5Ig5Z40ryteXoLu4,1434
25
25
  commands/__init__.py,sha256=g_ZxSKLVZwCVAPpn-Ga_gj53BS2473SOg72ivGph--U,147
26
26
  commands/adapter.py,sha256=_68v3t-dRU0mszzL4udKs1bKennyg7RfBTaK2fDGTsE,3215
@@ -28,39 +28,47 @@ commands/add.py,sha256=NJLQ-8-3zy7o6S9VLfL_wauP-Vz0oNGwN3nvtiwxNYM,15255
28
28
  commands/clean.py,sha256=RQBAfe6iCLsjMqUyVR55JdYX9MBqgrUuIrA8rFKs1J0,1102
29
29
  commands/clone.py,sha256=4APH34-yHjiXQIQwBnKOSEQ_sxV24_GKypcOJMfncvs,5912
30
30
  commands/completion.py,sha256=Y2KshaJ64vI1fcTR5z2KTJT7u9PPK_w8qMf5HK_q9ns,8570
31
- commands/config.py,sha256=4kzDEjVpwrmMPK_DPYePdQe2lGh_b8waYORZDHCDYZw,6976
31
+ commands/config.py,sha256=zUh65j4N1R5ikRst18eKCvGFQWnhm7fphu314J9lLpo,15211
32
32
  commands/diff.py,sha256=37JMjvfAEfvK7-4X5iFbD-IGkS8ae4YSY7ZDIZF5B9E,5766
33
- commands/exec.py,sha256=zFmxxclpHQF39sqDpR5436XQiEYo334BGcQ5a8gbR9I,8711
33
+ commands/exec.py,sha256=rO18v7HOcJ9FQ71OHcR4Pplj_YTDD9Z6GxfoWGpK0f0,9518
34
34
  commands/find.py,sha256=zgqwUnaG5aLX6gJIU2ZeQzxsFh2s7oDNNtmV-e-62Jg,1663
35
35
  commands/help.py,sha256=5yhIpgGs1xPs2f39lg-ELE7D0tV_uUTjxQsgkWusIwo,1449
36
36
  commands/info.py,sha256=zywaUQsrvcPXcX8W49P7Jqnr90pX8nBPqnH1XcIs0Uk,4396
37
37
  commands/list.py,sha256=P3E_PItNoqAStNTcLCY7dV-Db2_Yb7TRCYXgP4G19rQ,3185
38
+ commands/profile.py,sha256=bjfdXJ6HeM8NRSsEn4t4kbHxSK0o6M3fHc7Cr9RH90U,2732
38
39
  commands/registry.py,sha256=nerDoR0Uvd_2WlDMQZshJunMW31HsENklAy19A00x4U,16065
39
40
  commands/rm.py,sha256=bOPXgifAbgx7kp4ZuNanJbv4wP1l1sIsI8vD6Rlgi8g,4160
40
41
  commands/status.py,sha256=8si3iEVu0AUE2qojSCVoU0BLwpLm4UiFyY-Ln7Uo36k,3944
41
42
  commands/sync.py,sha256=ZQoB5hBqrzvM6LUQVlKqHQVJib4dB5qe5M-pVG2vtGM,4946
42
- commands/update.py,sha256=bOWjdTNyeYg-hvXv5GfUzEtsTA7gU9JLM592GI9Oq68,11939
43
+ commands/update.py,sha256=gWBBETMxi1ZQ3BV049eAkA_AuCEvvjA9FRzIOBpwdb4,15062
43
44
  commands/use.py,sha256=ggB28g2BDg3Lv3nF40wnDAJ7p0mo6C1pc1KgahvQYXM,1452
44
45
  commands/validate.py,sha256=Y8TLHxW4Z98onmzu-h-kDIET-48lVaIdQXOvuyBemLw,2361
45
46
  completions/__init__.py,sha256=cLGMHifEf91ElOIMSVffVWcifjGZ7oStb0Ot4ivLmmE,41
46
- completions/bash.sh,sha256=gqwuOqUDayPQ5_fHUtttJ_AK-Jef-vgSkUATbbtJBic,6169
47
- completions/fish.fish,sha256=LXUPp3Ad8aJ7j9m_xlX9Ej9H-3xLUhhMSzsB02oH8Vw,8042
48
- completions/powershell.ps1,sha256=dRtY-w8Hd3EmmJTtoDdkYzFN6tYiUtCeuu0pufUFsps,4210
49
- completions/zsh.sh,sha256=Pt-jfSHy8Q5kRtn314ejmP3_VCQGzcHAmK_buOKE4JI,6751
50
- config/__init__.py,sha256=glSjT1_y4aOfhZ8odrUWCGF1hBbY_huTjVp6suepHDY,3647
51
- config/defaults.py,sha256=JfCltQYoE7EqBYlxsNrSITLmwifTvRrJe5lqL0Ys7Cs,986
47
+ completions/bash.sh,sha256=2gk60fm17lPfBddpOvO0c8o0anGmqzbVrtic9pabjnQ,6647
48
+ completions/fish.fish,sha256=N2xXseX2djUNRGDs9TaUNU6YltmNT0PSEK0rYJJSuXc,10160
49
+ completions/powershell.ps1,sha256=m-EsgUpyvg1-wO6Afo2QnmHkzdmBfetDUMgaHn4vlO4,7614
50
+ completions/zsh.sh,sha256=XZRLOEi2_grWACeGiDeucsnyFlKwx_SuU7RMBOPe7Ng,7624
51
+ config/__init__.py,sha256=KcrfpKlWkDqVYooupSqMcfYstUDMpVgdSeOJv38nGS0,4063
52
+ config/defaults.py,sha256=1M5rxaOV-51vKAX_CaUZ8-AFL0vnklqYQeo7BpbOsJ8,525
52
53
  config/env.py,sha256=WgnQXjhfvV7m1oxZCK9WdIX_rqLy_-BOSuPjbpjdI1c,7163
53
- config/schema.py,sha256=VlvmiYWjU2hExBJfME90Oyqp-H4OHcUs_hvvp54K9jA,4498
54
+ config/schema.py,sha256=kQ1EeepRHjjf4dy9y_qRw7DKEEjeTtyvzd2MZLSIAYY,2816
54
55
  policy/__init__.py,sha256=0sPJaruOyc9ioNyIcrTW72RgpaE64FgibS0h5mQELb8,1353
55
- policy/defaults.py,sha256=9GMQM2l2OKTmhXlKwyTfcICR5vD9qEvyvqaR5KrN7ZI,620
56
+ policy/defaults.py,sha256=7DHRzeW79pEPx7x0_8dTmaFz9cpCosmto_yzb-cpMDs,233
56
57
  policy/enforcement.py,sha256=djsosjjfdyr0SjnHF2kz4u3glvMNgd1CJztN6yZE-fM,2749
57
- policy/profile.py,sha256=WDKaUagsWnBPGz5a_OOcxTsdZ66WjaIaR0R7ITVqy8g,6790
58
+ policy/profile.py,sha256=j9C4Ut5Hwa6DotbbFR1ggmDPVmEC4dNMW7D4oxz2kxM,6839
59
+ profiles/__init__.py,sha256=IqlTPlQdKibdLFMbYIk_tpn_B7dmMSnPhN_OJwPzgAo,760
60
+ profiles/builtins.py,sha256=cu3lvUIUZw-OxEYJgGQeDJvQMPuwTnJqxYTOvnf5oiM,1430
61
+ profiles/loader.py,sha256=n6FJm1r_PhAtuCVlIom2I3yqxnTQUryU8b7ggYyUJjI,2487
62
+ profiles/paths.py,sha256=l0TbuVOOLrJBXOR-BAnxxA__2wZ9rHDTNHANTK1xafo,536
63
+ profiles/registry.py,sha256=hkLUXaN-57iAxQtTwnDkHzLrzl3HTJPF44JcBfg_rjg,659
64
+ profiles/summary.py,sha256=jLbmROC4TqyUJgswsn73rJqPkYq0YmAoKKoWxx5XBRg,853
65
+ profiles/validation.py,sha256=J9pZBs9l-PpNmYoRdn43UhA07FSDM8EdGpZMpsTikvY,1920
58
66
  skillcopy/__init__.py,sha256=YUglUkDzKfnCt4ar_DU33ksI9fGyn2UYbV7qn2c_BcU,2322
59
67
  skillcopy/local.py,sha256=QH6484dCenjg8pfNOyTRbQQBklEWhkkTnfQok5ssf_4,1049
60
68
  skillcopy/remote.py,sha256=83jRA2VfjtSDGO-YM1x3WGJjKvWzK1RmSTL7SdUOz8s,3155
61
- oasr-0.5.2.dist-info/METADATA,sha256=SQ26Gtaa3LH-7f5CSHfM1yLUzNmizhYEuWO9lqTqSVM,18413
62
- oasr-0.5.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
63
- oasr-0.5.2.dist-info/entry_points.txt,sha256=VnMuOi6XYMbzAD2bP0X5uV1sQXjOqoDWJ33Lsxwq8u8,52
64
- oasr-0.5.2.dist-info/licenses/LICENSE,sha256=nQ1j9Ldb8FlJ-z7y2WuXPIlyfnYC7YPasjGdOBgcfP4,10561
65
- oasr-0.5.2.dist-info/licenses/NOTICE,sha256=EsfkCN0ZRDS0oj3ADvMKeKrAXaPlC8YfpSjvjGVv9jE,207
66
- oasr-0.5.2.dist-info/RECORD,,
69
+ oasr-0.6.0.dist-info/METADATA,sha256=ueWMiIfDSfj_PC73af_g5NRMgGcVaxiEYU_YM9k-3Fo,18919
70
+ oasr-0.6.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
71
+ oasr-0.6.0.dist-info/entry_points.txt,sha256=VnMuOi6XYMbzAD2bP0X5uV1sQXjOqoDWJ33Lsxwq8u8,52
72
+ oasr-0.6.0.dist-info/licenses/LICENSE,sha256=nQ1j9Ldb8FlJ-z7y2WuXPIlyfnYC7YPasjGdOBgcfP4,10561
73
+ oasr-0.6.0.dist-info/licenses/NOTICE,sha256=EsfkCN0ZRDS0oj3ADvMKeKrAXaPlC8YfpSjvjGVv9jE,207
74
+ oasr-0.6.0.dist-info/RECORD,,
policy/defaults.py CHANGED
@@ -6,22 +6,6 @@ Conservative defaults that fail closed. Used when:
6
6
  - Config parsing errors occur
7
7
  """
8
8
 
9
- # Safe default profile - conservative and restrictive
10
- SAFE = {
11
- "fs_read_roots": ["./"],
12
- "fs_write_roots": ["./out", "./.oasr"],
13
- "deny_paths": [
14
- "~/.ssh",
15
- "~/.aws",
16
- "~/.gnupg",
17
- "~/.config",
18
- ".env",
19
- "~/.bashrc",
20
- "~/.zshrc",
21
- "~/.profile",
22
- ],
23
- "allowed_commands": ["rg", "fd", "jq", "cat"],
24
- "deny_shell": True,
25
- "network": False,
26
- "allow_env": False,
27
- }
9
+ from profiles.builtins import SAFE
10
+
11
+ __all__ = ["SAFE"]
policy/profile.py CHANGED
@@ -11,7 +11,7 @@ from dataclasses import dataclass, field
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
- from policy.defaults import SAFE
14
+ from profiles import BUILTIN_PROFILES, merge_profile_data
15
15
 
16
16
 
17
17
  @dataclass
@@ -107,17 +107,15 @@ def load(config: dict[str, Any], profile_name: str, cwd: Path | None = None) ->
107
107
  def _load_impl(config: dict[str, Any], profile_name: str) -> Profile:
108
108
  """Internal implementation of load()."""
109
109
  # Start with safe defaults
110
- profile_data = SAFE.copy()
110
+ profile_data = BUILTIN_PROFILES["safe"].copy()
111
111
 
112
112
  # Try to load user-defined profile
113
113
  try:
114
114
  profiles = config.get("profiles", {})
115
115
  if profile_name in profiles:
116
- user_profile = profiles[profile_name]
117
- # Merge user overrides
118
- for key in SAFE.keys():
119
- if key in user_profile:
120
- profile_data[key] = user_profile[key]
116
+ profile_data = merge_profile_data(profile_data, profiles[profile_name])
117
+ elif profile_name in BUILTIN_PROFILES:
118
+ profile_data = merge_profile_data(profile_data, BUILTIN_PROFILES[profile_name])
121
119
  elif profile_name != "safe":
122
120
  # Warn if non-safe profile doesn't exist, but continue with safe defaults
123
121
  print(
profiles/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Profile loading utilities for OASR."""
2
+
3
+ from profiles.builtins import BUILTIN_PROFILE_ORDER, BUILTIN_PROFILES
4
+ from profiles.loader import load_profiles, merge_profile_data
5
+ from profiles.paths import ensure_profile_dir, get_profile_dir
6
+ from profiles.registry import get_profiles, list_profiles
7
+ from profiles.summary import format_profile_summary, sorted_profile_names
8
+ from profiles.validation import validate_profile_data, validate_profiles
9
+
10
+ __all__ = [
11
+ "BUILTIN_PROFILES",
12
+ "BUILTIN_PROFILE_ORDER",
13
+ "load_profiles",
14
+ "get_profile_dir",
15
+ "ensure_profile_dir",
16
+ "format_profile_summary",
17
+ "get_profiles",
18
+ "list_profiles",
19
+ "merge_profile_data",
20
+ "sorted_profile_names",
21
+ "validate_profile_data",
22
+ "validate_profiles",
23
+ ]
profiles/builtins.py ADDED
@@ -0,0 +1,63 @@
1
+ """Built-in execution policy profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ SAFE = {
8
+ "fs_read_roots": ["./"],
9
+ "fs_write_roots": ["./out", "./.oasr"],
10
+ "deny_paths": [
11
+ "~/.ssh",
12
+ "~/.aws",
13
+ "~/.gnupg",
14
+ "~/.config",
15
+ ".env",
16
+ "~/.bashrc",
17
+ "~/.zshrc",
18
+ "~/.profile",
19
+ ],
20
+ "allowed_commands": ["rg", "fd", "jq", "cat"],
21
+ "deny_shell": True,
22
+ "network": False,
23
+ "allow_env": False,
24
+ }
25
+
26
+ STRICT = {
27
+ "fs_read_roots": ["./"],
28
+ "fs_write_roots": ["./.oasr"],
29
+ "deny_paths": SAFE["deny_paths"],
30
+ "allowed_commands": ["rg", "cat"],
31
+ "deny_shell": True,
32
+ "network": False,
33
+ "allow_env": False,
34
+ }
35
+
36
+ DEV = {
37
+ "fs_read_roots": ["./", "~/projects"],
38
+ "fs_write_roots": ["./", "./out", "./.oasr", "~/projects"],
39
+ "deny_paths": SAFE["deny_paths"],
40
+ "allowed_commands": ["bash", "python", "node", "git", "curl", "rg", "fd", "jq", "cat", "npm", "pip"],
41
+ "deny_shell": False,
42
+ "network": True,
43
+ "allow_env": True,
44
+ }
45
+
46
+ UNSAFE = {
47
+ "fs_read_roots": ["/"],
48
+ "fs_write_roots": ["/"],
49
+ "deny_paths": [],
50
+ "allowed_commands": ["*"],
51
+ "deny_shell": False,
52
+ "network": True,
53
+ "allow_env": True,
54
+ }
55
+
56
+ BUILTIN_PROFILES: dict[str, dict[str, Any]] = {
57
+ "safe": SAFE,
58
+ "strict": STRICT,
59
+ "dev": DEV,
60
+ "unsafe": UNSAFE,
61
+ }
62
+
63
+ BUILTIN_PROFILE_ORDER = ("safe", "strict", "dev", "unsafe")
profiles/loader.py ADDED
@@ -0,0 +1,74 @@
1
+ """Profile loading and merging utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ if sys.version_info >= (3, 11):
10
+ import tomllib
11
+ else:
12
+ import tomli as tomllib
13
+
14
+ from profiles.builtins import BUILTIN_PROFILES
15
+ from profiles.paths import get_profile_dir
16
+
17
+
18
+ def list_profile_files(profile_dir: Path | None = None) -> list[Path]:
19
+ """Return profile files from ~/.oasr/profile."""
20
+ directory = profile_dir or get_profile_dir()
21
+ if not directory.exists():
22
+ return []
23
+ return sorted(p for p in directory.glob("*.toml") if p.is_file())
24
+
25
+
26
+ def load_profile_file(path: Path) -> dict[str, Any]:
27
+ """Load a profile TOML file containing only profile keys."""
28
+ with open(path, "rb") as f:
29
+ data = tomllib.load(f)
30
+ if not isinstance(data, dict):
31
+ raise ValueError("Profile file must contain a table of profile settings")
32
+ return data
33
+
34
+
35
+ def load_profile_files(profile_dir: Path | None = None) -> dict[str, dict[str, Any]]:
36
+ """Load profile files into a name->profile map."""
37
+ profiles: dict[str, dict[str, Any]] = {}
38
+ for path in list_profile_files(profile_dir):
39
+ name = path.stem
40
+ try:
41
+ profiles[name] = load_profile_file(path)
42
+ except Exception as exc:
43
+ print(f"⚠ Warning: Failed to load profile '{name}': {exc}", file=sys.stderr)
44
+ return profiles
45
+
46
+
47
+ def merge_profiles(
48
+ builtin: dict[str, dict[str, Any]],
49
+ file_profiles: dict[str, dict[str, Any]],
50
+ inline_profiles: dict[str, dict[str, Any]],
51
+ ) -> dict[str, dict[str, Any]]:
52
+ """Merge profiles with precedence: inline > file > builtin."""
53
+ merged: dict[str, dict[str, Any]] = {}
54
+ for source in (builtin, file_profiles, inline_profiles):
55
+ for name, profile in source.items():
56
+ merged[name] = profile.copy()
57
+ return merged
58
+
59
+
60
+ def merge_profile_data(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
61
+ """Merge profile values with override precedence."""
62
+ merged = base.copy()
63
+ merged.update(overrides)
64
+ return merged
65
+
66
+
67
+ def load_profiles(
68
+ inline_profiles: dict[str, dict[str, Any]] | None = None,
69
+ profile_dir: Path | None = None,
70
+ ) -> dict[str, dict[str, Any]]:
71
+ """Load merged profiles from builtin + profile files + inline config."""
72
+ inline_profiles = inline_profiles or {}
73
+ file_profiles = load_profile_files(profile_dir)
74
+ return merge_profiles(BUILTIN_PROFILES, file_profiles, inline_profiles)
profiles/paths.py ADDED
@@ -0,0 +1,22 @@
1
+ """Profile directory paths and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def get_profile_dir() -> Path:
9
+ """Return ~/.oasr/profile directory (dynamic)."""
10
+ try:
11
+ from config import OASR_DIR
12
+
13
+ return OASR_DIR / "profile"
14
+ except Exception:
15
+ return Path.home() / ".oasr" / "profile"
16
+
17
+
18
+ def ensure_profile_dir() -> Path:
19
+ """Ensure ~/.oasr/profile directory exists."""
20
+ directory = get_profile_dir()
21
+ directory.mkdir(parents=True, exist_ok=True)
22
+ return directory
profiles/registry.py ADDED
@@ -0,0 +1,19 @@
1
+ """Profile registry operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from profiles.loader import load_profiles
8
+ from profiles.summary import sorted_profile_names
9
+
10
+
11
+ def list_profiles(inline_profiles: dict[str, dict[str, Any]] | None = None) -> list[str]:
12
+ """Return ordered profile names with all sources merged."""
13
+ profiles = load_profiles(inline_profiles=inline_profiles or {})
14
+ return sorted_profile_names(profiles)
15
+
16
+
17
+ def get_profiles(inline_profiles: dict[str, dict[str, Any]] | None = None) -> dict[str, dict[str, Any]]:
18
+ """Return merged profiles."""
19
+ return load_profiles(inline_profiles=inline_profiles or {})
profiles/summary.py ADDED
@@ -0,0 +1,23 @@
1
+ """Profile listing and summary helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from profiles.builtins import BUILTIN_PROFILE_ORDER
8
+
9
+
10
+ def sorted_profile_names(profiles: dict[str, Any]) -> list[str]:
11
+ """Return profile names sorted with built-ins first."""
12
+ names = sorted(profiles.keys())
13
+ ordered = [name for name in BUILTIN_PROFILE_ORDER if name in profiles]
14
+ remaining = [name for name in names if name not in ordered]
15
+ return ordered + remaining
16
+
17
+
18
+ def format_profile_summary(name: str, profile: dict[str, Any]) -> str:
19
+ """Format a single-line profile summary."""
20
+ network = "on" if profile.get("network") else "off"
21
+ env = "on" if profile.get("allow_env") else "off"
22
+ shell = "on" if not profile.get("deny_shell", True) else "off"
23
+ return f"{name:12} network={network} env={env} shell={shell}"
profiles/validation.py ADDED
@@ -0,0 +1,34 @@
1
+ """Profile validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def validate_profile_data(profile_name: str, profile_data: dict[str, Any]) -> None:
9
+ """Validate a single profile data structure."""
10
+ if "fs_read_roots" in profile_data and not isinstance(profile_data["fs_read_roots"], list):
11
+ raise ValueError(f"Profile '{profile_name}': fs_read_roots must be a list")
12
+ if "fs_write_roots" in profile_data and not isinstance(profile_data["fs_write_roots"], list):
13
+ raise ValueError(f"Profile '{profile_name}': fs_write_roots must be a list")
14
+ if "deny_paths" in profile_data and not isinstance(profile_data["deny_paths"], list):
15
+ raise ValueError(f"Profile '{profile_name}': deny_paths must be a list")
16
+ if "allowed_commands" in profile_data and not isinstance(profile_data["allowed_commands"], list):
17
+ raise ValueError(f"Profile '{profile_name}': allowed_commands must be a list")
18
+ if "deny_shell" in profile_data and not isinstance(profile_data["deny_shell"], bool):
19
+ raise ValueError(f"Profile '{profile_name}': deny_shell must be a boolean")
20
+ if "network" in profile_data and not isinstance(profile_data["network"], bool):
21
+ raise ValueError(f"Profile '{profile_name}': network must be a boolean")
22
+ if "allow_env" in profile_data and not isinstance(profile_data["allow_env"], bool):
23
+ raise ValueError(f"Profile '{profile_name}': allow_env must be a boolean")
24
+
25
+
26
+ def validate_profiles(profiles: dict[str, Any]) -> None:
27
+ """Validate profile map structure."""
28
+ if not isinstance(profiles, dict):
29
+ raise ValueError("profiles must be a table (dictionary)")
30
+
31
+ for profile_name, profile_data in profiles.items():
32
+ if not isinstance(profile_data, dict):
33
+ raise ValueError(f"Profile '{profile_name}' must be a table (dictionary)")
34
+ validate_profile_data(profile_name, profile_data)
File without changes