oto-core 1.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.
Files changed (153) hide show
  1. oto/config.py +291 -0
  2. oto/scaleway_secrets.py +131 -0
  3. oto/sops_secrets.py +149 -0
  4. oto/tools/__init__.py +1 -0
  5. oto/tools/anthropic/__init__.py +5 -0
  6. oto/tools/anthropic/client.py +354 -0
  7. oto/tools/anthropic_batch/__init__.py +5 -0
  8. oto/tools/anthropic_batch/client.py +352 -0
  9. oto/tools/apollo/__init__.py +5 -0
  10. oto/tools/apollo/client.py +191 -0
  11. oto/tools/attio/__init__.py +5 -0
  12. oto/tools/attio/client.py +616 -0
  13. oto/tools/audio/__init__.py +5 -0
  14. oto/tools/audio/client.py +116 -0
  15. oto/tools/boamp/__init__.py +3 -0
  16. oto/tools/boamp/client.py +69 -0
  17. oto/tools/bodacc/__init__.py +3 -0
  18. oto/tools/bodacc/client.py +4 -0
  19. oto/tools/browser/__init__.py +39 -0
  20. oto/tools/browser/crunchbase.py +423 -0
  21. oto/tools/browser/g2.py +236 -0
  22. oto/tools/browser/google.py +104 -0
  23. oto/tools/browser/indeed.py +282 -0
  24. oto/tools/browser/linkedin/__init__.py +5 -0
  25. oto/tools/browser/linkedin/_js.py +255 -0
  26. oto/tools/browser/linkedin/client.py +277 -0
  27. oto/tools/browser/linkedin/outreach.py +264 -0
  28. oto/tools/browser/linkedin/scrape.py +228 -0
  29. oto/tools/browser/linkedin/search.py +273 -0
  30. oto/tools/browser/pappers.py +344 -0
  31. oto/tools/browser/sncf.py +112 -0
  32. oto/tools/clearbit/__init__.py +5 -0
  33. oto/tools/clearbit/client.py +126 -0
  34. oto/tools/collective/__init__.py +5 -0
  35. oto/tools/collective/client.py +333 -0
  36. oto/tools/common/__init__.py +5 -0
  37. oto/tools/common/rate_limiter.py +475 -0
  38. oto/tools/culture/__init__.py +10 -0
  39. oto/tools/culture/opendatasoft.py +117 -0
  40. oto/tools/culture/spectacle.py +174 -0
  41. oto/tools/datastore/__init__.py +4 -0
  42. oto/tools/datastore/client.py +99 -0
  43. oto/tools/dvf/__init__.py +3 -0
  44. oto/tools/dvf/client.py +4 -0
  45. oto/tools/figma/__init__.py +5 -0
  46. oto/tools/figma/client.py +254 -0
  47. oto/tools/folk/__init__.py +5 -0
  48. oto/tools/folk/client.py +203 -0
  49. oto/tools/fullenrich/__init__.py +0 -0
  50. oto/tools/fullenrich/client.py +184 -0
  51. oto/tools/gemini/__init__.py +5 -0
  52. oto/tools/gemini/client.py +264 -0
  53. oto/tools/gocardless/__init__.py +5 -0
  54. oto/tools/gocardless/client.py +270 -0
  55. oto/tools/google/__init__.py +1 -0
  56. oto/tools/google/calendar/__init__.py +0 -0
  57. oto/tools/google/calendar/commands.py +100 -0
  58. oto/tools/google/calendar/lib/__init__.py +0 -0
  59. oto/tools/google/calendar/lib/calendar_client.py +195 -0
  60. oto/tools/google/credentials.py +177 -0
  61. oto/tools/google/docs/commands.py +91 -0
  62. oto/tools/google/docs/lib/__init__.py +0 -0
  63. oto/tools/google/docs/lib/docs_client.py +525 -0
  64. oto/tools/google/docs/lib/markdown_to_docs.py +274 -0
  65. oto/tools/google/docs/lib/markdown_to_html.py +98 -0
  66. oto/tools/google/docs/sync/__init__.py +1 -0
  67. oto/tools/google/drive/check_quota.py +31 -0
  68. oto/tools/google/drive/commands.py +140 -0
  69. oto/tools/google/drive/lib/drive_client.py +546 -0
  70. oto/tools/google/drive/list_shared_drives.py +32 -0
  71. oto/tools/google/gmail/__init__.py +1 -0
  72. oto/tools/google/gmail/commands.py +223 -0
  73. oto/tools/google/gmail/lib/__init__.py +1 -0
  74. oto/tools/google/gmail/lib/gmail_client.py +482 -0
  75. oto/tools/google/keep/__init__.py +0 -0
  76. oto/tools/google/keep/lib/__init__.py +0 -0
  77. oto/tools/google/keep/lib/keep_client.py +273 -0
  78. oto/tools/google/sheets/commands.py +85 -0
  79. oto/tools/google/sheets/lib/__init__.py +0 -0
  80. oto/tools/google/sheets/lib/sheets_client.py +158 -0
  81. oto/tools/google/slides/commands.py +136 -0
  82. oto/tools/google/slides/lib/__init__.py +1 -0
  83. oto/tools/google/slides/lib/slides_client.py +1516 -0
  84. oto/tools/google/tasks/__init__.py +0 -0
  85. oto/tools/google/tasks/commands.py +137 -0
  86. oto/tools/google/tasks/lib/__init__.py +0 -0
  87. oto/tools/google/tasks/lib/tasks_client.py +162 -0
  88. oto/tools/groq/__init__.py +5 -0
  89. oto/tools/groq/client.py +165 -0
  90. oto/tools/hithorizons/__init__.py +5 -0
  91. oto/tools/hithorizons/client.py +168 -0
  92. oto/tools/hunter/__init__.py +5 -0
  93. oto/tools/hunter/client.py +104 -0
  94. oto/tools/inpi/__init__.py +3 -0
  95. oto/tools/inpi/client.py +4 -0
  96. oto/tools/kaspr/__init__.py +5 -0
  97. oto/tools/kaspr/client.py +97 -0
  98. oto/tools/lemlist/__init__.py +5 -0
  99. oto/tools/lemlist/client.py +486 -0
  100. oto/tools/markdown_lint.py +62 -0
  101. oto/tools/mistral/__init__.py +5 -0
  102. oto/tools/mistral/client.py +149 -0
  103. oto/tools/naf/__init__.py +5 -0
  104. oto/tools/naf/suggester.py +140 -0
  105. oto/tools/ninja/__init__.py +4 -0
  106. oto/tools/ninja/client.py +99 -0
  107. oto/tools/notion/lib/markdown_converter.py +287 -0
  108. oto/tools/notion/lib/notion_client.py +315 -0
  109. oto/tools/openai/__init__.py +5 -0
  110. oto/tools/openai/client.py +167 -0
  111. oto/tools/pdf/__init__.py +89 -0
  112. oto/tools/pdf/templates/default.css +114 -0
  113. oto/tools/pennylane/__init__.py +5 -0
  114. oto/tools/pennylane/client.py +458 -0
  115. oto/tools/phantombuster/__init__.py +5 -0
  116. oto/tools/phantombuster/client.py +177 -0
  117. oto/tools/reddit/__init__.py +5 -0
  118. oto/tools/reddit/client.py +216 -0
  119. oto/tools/resend/__init__.py +5 -0
  120. oto/tools/resend/client.py +149 -0
  121. oto/tools/serpapi/__init__.py +5 -0
  122. oto/tools/serpapi/client.py +128 -0
  123. oto/tools/serper/__init__.py +5 -0
  124. oto/tools/serper/client.py +222 -0
  125. oto/tools/sirene/__init__.py +34 -0
  126. oto/tools/sirene/client.py +19 -0
  127. oto/tools/sirene/data/naf_codes.txt +732 -0
  128. oto/tools/sirene/entreprises.py +4 -0
  129. oto/tools/sirene/stock.py +184 -0
  130. oto/tools/slack/__init__.py +5 -0
  131. oto/tools/slack/client.py +383 -0
  132. oto/tools/supabase/__init__.py +4 -0
  133. oto/tools/supabase/client.py +98 -0
  134. oto/tools/unsplash/__init__.py +5 -0
  135. oto/tools/unsplash/client.py +197 -0
  136. oto/tools/whatsapp/__init__.py +3 -0
  137. oto/tools/whatsapp/client.py +120 -0
  138. oto/tools/whatsapp/node/package-lock.json +1564 -0
  139. oto/tools/whatsapp/node/package.json +11 -0
  140. oto/tools/whatsapp/node/whatsapp.mjs +382 -0
  141. oto/tools/wttj/__init__.py +5 -0
  142. oto/tools/wttj/client.py +251 -0
  143. oto/tools/zerobounce/__init__.py +5 -0
  144. oto/tools/zerobounce/client.py +93 -0
  145. oto/tools/zoho/__init__.py +5 -0
  146. oto/tools/zoho/client.py +184 -0
  147. oto/tools/zohodesk/__init__.py +5 -0
  148. oto/tools/zohodesk/client.py +190 -0
  149. oto_core-1.6.0.dist-info/METADATA +67 -0
  150. oto_core-1.6.0.dist-info/RECORD +153 -0
  151. oto_core-1.6.0.dist-info/WHEEL +5 -0
  152. oto_core-1.6.0.dist-info/licenses/LICENSE +21 -0
  153. oto_core-1.6.0.dist-info/top_level.txt +1 -0
oto/config.py ADDED
@@ -0,0 +1,291 @@
1
+ """Configuration loader for otomata tools.
2
+
3
+ Secret resolution order:
4
+ 1. Environment variable (always)
5
+ 2. Configured provider (sops, file, or scaleway)
6
+ 3. Default value
7
+ """
8
+
9
+ import os
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Optional, Dict, Any
13
+
14
+ # Cache for parsed secrets files
15
+ _secrets_cache: Dict[Path, Dict[str, str]] = {}
16
+ _oto_config_cache: Optional[Dict[str, Any]] = None
17
+
18
+
19
+ class AmbiguousSecretError(RuntimeError):
20
+ """The key exists in the vault but with different values across files.
21
+
22
+ Raised by get_secret: returning one of the values would be arbitrary
23
+ (the key is scoped per project/mission file, not transverse)."""
24
+
25
+
26
+ def _parse_env_file(path: Path) -> Dict[str, str]:
27
+ """Parse a .env file into a dictionary."""
28
+ if path in _secrets_cache:
29
+ return _secrets_cache[path]
30
+
31
+ result = {}
32
+ if path.exists():
33
+ with open(path, "r") as f:
34
+ for line in f:
35
+ line = line.strip()
36
+ if not line or line.startswith("#"):
37
+ continue
38
+ if "=" in line:
39
+ key, value = line.split("=", 1)
40
+ value = value.strip()
41
+ # Remove quotes if present
42
+ if (value.startswith("'") and value.endswith("'")) or (
43
+ value.startswith('"') and value.endswith('"')
44
+ ):
45
+ value = value[1:-1]
46
+ result[key.strip()] = value
47
+
48
+ _secrets_cache[path] = result
49
+ return result
50
+
51
+
52
+ def _find_project_secrets() -> Optional[Path]:
53
+ """Find .otomata/secrets.env in CWD or parent directories."""
54
+ cwd = Path.cwd()
55
+
56
+ # Check CWD and up to 4 parent levels
57
+ for _ in range(5):
58
+ secrets_file = cwd / ".otomata" / "secrets.env"
59
+ if secrets_file.exists():
60
+ return secrets_file
61
+ if cwd.parent == cwd:
62
+ break
63
+ cwd = cwd.parent
64
+
65
+ return None
66
+
67
+
68
+ def _get_user_secrets() -> Path:
69
+ """Get user secrets file path (~/.otomata/secrets.env)."""
70
+ return Path.home() / ".otomata" / "secrets.env"
71
+
72
+
73
+ def _get_oto_config() -> Dict[str, Any]:
74
+ """Read ~/.otomata/config.yaml. Cached."""
75
+ global _oto_config_cache
76
+ if _oto_config_cache is not None:
77
+ return _oto_config_cache
78
+
79
+ config_file = Path.home() / ".otomata" / "config.yaml"
80
+ if config_file.exists():
81
+ import yaml
82
+ with open(config_file) as f:
83
+ _oto_config_cache = yaml.safe_load(f) or {}
84
+ else:
85
+ _oto_config_cache = {}
86
+ return _oto_config_cache
87
+
88
+
89
+ def get_provider() -> str:
90
+ """Return configured secret provider ('sops', 'file', or 'scaleway').
91
+
92
+ `sops` is the new default — secrets decrypted on demand from a SOPS
93
+ YAML file (see `oto.sops_secrets`). `file` and `scaleway` are kept for
94
+ backwards compat and migration.
95
+ """
96
+ return _get_oto_config().get("secret_provider", "sops")
97
+
98
+
99
+ def write_oto_config(config: Dict[str, Any]) -> None:
100
+ """Write ~/.otomata/config.yaml."""
101
+ global _oto_config_cache
102
+ import yaml
103
+ config_file = get_config_dir() / "config.yaml"
104
+ with open(config_file, "w") as f:
105
+ yaml.dump(config, f, default_flow_style=False)
106
+ _oto_config_cache = config
107
+
108
+
109
+ def get_search_provider() -> str:
110
+ """Return configured search provider ('serper' or 'browser')."""
111
+ return _get_oto_config().get("search_provider", "serper")
112
+
113
+
114
+ _MISSING = object()
115
+
116
+
117
+ def _file_provider_lookup(name: str):
118
+ """Look `name` up in the file provider (project secrets, then user secrets).
119
+
120
+ Returns the value, or the `_MISSING` sentinel when not present (so an empty
121
+ string stored on purpose is distinguishable from "not found").
122
+ """
123
+ project_secrets = _find_project_secrets()
124
+ if project_secrets:
125
+ secrets = _parse_env_file(project_secrets)
126
+ if name in secrets:
127
+ return secrets[name]
128
+ user_secrets = _get_user_secrets()
129
+ secrets = _parse_env_file(user_secrets)
130
+ if name in secrets:
131
+ return secrets[name]
132
+ return _MISSING
133
+
134
+
135
+ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
136
+ """
137
+ Get a secret value.
138
+
139
+ Resolution order:
140
+ 1. Environment variable (always, highest priority)
141
+ 2. Configured provider (sops / scaleway / file)
142
+ 3. Local file provider as a graceful fallback when the configured provider
143
+ has no backing store (e.g. fresh/third-party install with sops default
144
+ but no SOPS repo cloned)
145
+ 4. Default value
146
+
147
+ `get_secret` is the soft accessor: it honours its contract and returns
148
+ `default` when a secret can't be resolved — it never raises just because a
149
+ provider's store is absent. The hard failure (with guidance) belongs to
150
+ `require_secret`.
151
+
152
+ Exception: a key present in the vault with DIFFERENT values across files
153
+ raises AmbiguousSecretError — returning one of them would be arbitrary.
154
+
155
+ Args:
156
+ name: Secret name (e.g., 'GROQ_API_KEY', 'SIRENE_API_KEY')
157
+ default: Default value if not found
158
+
159
+ Returns:
160
+ Secret value or default
161
+ """
162
+ # 1. Environment variable (always)
163
+ env_val = os.environ.get(name)
164
+ if env_val:
165
+ return env_val
166
+
167
+ # Server hardening (oto-mcp#12) : OTO_CONFIG_DISABLE_SOPS=1 ⇒ le process ne
168
+ # résout QUE son environnement — ni SOPS, ni ~/.otomata/secrets.env. Les
169
+ # serveurs (oto-mcp) tirent leurs credentials de leur propre store (DB,
170
+ # injection) ; une lecture filesystem silencieuse ici contournerait ça.
171
+ if os.environ.get("OTO_CONFIG_DISABLE_SOPS") == "1":
172
+ return default
173
+
174
+ # 2. Configured provider. `store_missing` marks the case where the provider
175
+ # is configured but has no backing store, so we can fall back to the
176
+ # local file provider instead of failing.
177
+ provider = get_provider()
178
+ store_missing = False
179
+ if provider == "sops":
180
+ from oto.sops_secrets import fetch_secrets as _sops_fetch, ambiguous_keys
181
+ cfg = _get_oto_config()
182
+ try:
183
+ secrets = _sops_fetch(
184
+ path=cfg.get("sops_file"),
185
+ dir_path=cfg.get("sops_dir"),
186
+ )
187
+ if name in secrets:
188
+ return secrets[name]
189
+ if name in ambiguous_keys():
190
+ files = ambiguous_keys()[name]
191
+ raise AmbiguousSecretError(
192
+ f"Secret '{name}' is defined with DIFFERENT values in several "
193
+ f"vault files: {', '.join(files)}. It is scoped per file, not "
194
+ f"transverse — read the relevant file directly "
195
+ f"(`sops -d <file>`) or pass it via the environment."
196
+ )
197
+ except FileNotFoundError:
198
+ store_missing = True
199
+ elif provider == "scaleway":
200
+ from oto.scaleway_secrets import fetch_secrets
201
+ secrets = fetch_secrets()
202
+ if name in secrets:
203
+ return secrets[name]
204
+ else:
205
+ # File provider is the primary lookup.
206
+ value = _file_provider_lookup(name)
207
+ if value is not _MISSING:
208
+ return value
209
+
210
+ # 3. Graceful fallback to local file secrets when the configured provider's
211
+ # store is absent (sops default but no SOPS repo on a third-party box).
212
+ if store_missing:
213
+ value = _file_provider_lookup(name)
214
+ if value is not _MISSING:
215
+ return value
216
+
217
+ return default
218
+
219
+
220
+ def get_json_secret(name: str) -> Optional[Dict[str, Any]]:
221
+ """
222
+ Get a secret that contains JSON data.
223
+
224
+ Args:
225
+ name: Secret name
226
+
227
+ Returns:
228
+ Parsed JSON as dictionary, or None if not found
229
+ """
230
+ value = get_secret(name)
231
+ if value:
232
+ try:
233
+ return json.loads(value)
234
+ except json.JSONDecodeError:
235
+ return None
236
+ return None
237
+
238
+
239
+ def require_secret(name: str) -> str:
240
+ """
241
+ Get a required secret, raise error if not found.
242
+
243
+ Args:
244
+ name: Secret name
245
+
246
+ Returns:
247
+ Secret value
248
+
249
+ Raises:
250
+ ValueError: If secret not found
251
+ """
252
+ value = get_secret(name)
253
+ if value is None:
254
+ if os.environ.get("OTO_CONFIG_DISABLE_SOPS") == "1":
255
+ raise ValueError(
256
+ f"Required secret '{name}' not found — filesystem secret stores are "
257
+ f"disabled (OTO_CONFIG_DISABLE_SOPS=1, server mode). Provide it via "
258
+ f"the process environment or the service's credential store (DB)."
259
+ )
260
+ provider = get_provider()
261
+ raise ValueError(
262
+ f"Required secret '{name}' not found. Set it via:\n"
263
+ f" - Environment variable: export {name}='...' (always wins, simplest)\n"
264
+ f" - Local file provider: `oto config provider secrets file`, then add\n"
265
+ f" {name}=... to ~/.otomata/secrets.env\n"
266
+ f" - SOPS provider (otomata infra): keep `secret_provider: sops` and add the\n"
267
+ f" key to your SOPS store\n"
268
+ f" (current provider: {provider})"
269
+ )
270
+ return value
271
+
272
+
273
+ def get_config_dir() -> Path:
274
+ """Get otomata config directory (~/.otomata/)."""
275
+ config_dir = Path.home() / ".otomata"
276
+ config_dir.mkdir(parents=True, exist_ok=True)
277
+ return config_dir
278
+
279
+
280
+ def get_cache_dir() -> Path:
281
+ """Get otomata cache directory (~/.cache/otomata/)."""
282
+ cache_dir = Path.home() / ".cache" / "otomata"
283
+ cache_dir.mkdir(parents=True, exist_ok=True)
284
+ return cache_dir
285
+
286
+
287
+ def get_sessions_dir() -> Path:
288
+ """Get browser sessions directory (~/.otomata/sessions/)."""
289
+ sessions_dir = get_config_dir() / "sessions"
290
+ sessions_dir.mkdir(parents=True, exist_ok=True)
291
+ return sessions_dir
@@ -0,0 +1,131 @@
1
+ """Scaleway Secret Manager client for oto secrets.
2
+
3
+ Stores all secrets as a single JSON payload in one Scaleway secret ('otomata-secrets').
4
+ Auth via ~/.config/scw/config.yaml (same as scw CLI).
5
+ """
6
+
7
+ import base64
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Dict, Optional
11
+
12
+ import requests
13
+
14
+ _SCW_CONFIG = Path.home() / ".config" / "scw" / "config.yaml"
15
+ _BASE_URL = "https://api.scaleway.com/secret-manager/v1beta1/regions/{region}/secrets"
16
+ _SECRET_NAME = "otomata-secrets"
17
+
18
+ # Module-level cache — one API call per process
19
+ _cache: Optional[Dict[str, str]] = None
20
+
21
+
22
+ def _load_scw_credentials() -> dict:
23
+ """Load Scaleway credentials from ~/.config/scw/config.yaml."""
24
+ if not _SCW_CONFIG.exists():
25
+ raise ValueError(
26
+ f"Scaleway config not found at {_SCW_CONFIG}. "
27
+ "Run 'scw init' to configure."
28
+ )
29
+
30
+ # Parse YAML manually (top-level key: value lines) to avoid pyyaml dependency here
31
+ # Full YAML parsing is in config.py where pyyaml is available
32
+ import yaml
33
+
34
+ with open(_SCW_CONFIG) as f:
35
+ data = yaml.safe_load(f)
36
+
37
+ required = ("access_key", "secret_key", "default_project_id")
38
+ for key in required:
39
+ if key not in data:
40
+ raise ValueError(
41
+ f"Missing '{key}' in {_SCW_CONFIG}. Run 'scw init'."
42
+ )
43
+
44
+ return {
45
+ "secret_key": data["secret_key"],
46
+ "project_id": data["default_project_id"],
47
+ "region": data.get("default_region", "fr-par"),
48
+ }
49
+
50
+
51
+ def _headers(creds: dict) -> dict:
52
+ return {
53
+ "X-Auth-Token": creds["secret_key"],
54
+ "Content-Type": "application/json",
55
+ }
56
+
57
+
58
+ def _base_url(creds: dict) -> str:
59
+ return _BASE_URL.format(region=creds["region"])
60
+
61
+
62
+ def _find_secret_id(creds: dict) -> Optional[str]:
63
+ """Find the otomata-secrets secret ID, or None."""
64
+ resp = requests.get(
65
+ _base_url(creds),
66
+ headers=_headers(creds),
67
+ params={"name": _SECRET_NAME, "project_id": creds["project_id"]},
68
+ timeout=10,
69
+ )
70
+ resp.raise_for_status()
71
+ secrets = resp.json().get("secrets", [])
72
+ return secrets[0]["id"] if secrets else None
73
+
74
+
75
+ def fetch_secrets() -> Dict[str, str]:
76
+ """Fetch all secrets from Scaleway Secret Manager. Cached per process."""
77
+ global _cache
78
+ if _cache is not None:
79
+ return _cache
80
+
81
+ creds = _load_scw_credentials()
82
+ secret_id = _find_secret_id(creds)
83
+ if not secret_id:
84
+ _cache = {}
85
+ return _cache
86
+
87
+ resp = requests.get(
88
+ f"{_base_url(creds)}/{secret_id}/versions/latest/access",
89
+ headers=_headers(creds),
90
+ timeout=10,
91
+ )
92
+ resp.raise_for_status()
93
+
94
+ raw = base64.b64decode(resp.json()["data"])
95
+ _cache = json.loads(raw)
96
+ return _cache
97
+
98
+
99
+ def push_secrets(secrets: Dict[str, str]) -> str:
100
+ """Push secrets dict to Scaleway SM. Creates secret if needed. Returns version ID."""
101
+ creds = _load_scw_credentials()
102
+ secret_id = _find_secret_id(creds)
103
+
104
+ if not secret_id:
105
+ resp = requests.post(
106
+ _base_url(creds),
107
+ headers=_headers(creds),
108
+ json={
109
+ "name": _SECRET_NAME,
110
+ "project_id": creds["project_id"],
111
+ "type": "opaque",
112
+ },
113
+ timeout=10,
114
+ )
115
+ resp.raise_for_status()
116
+ secret_id = resp.json()["id"]
117
+
118
+ payload = base64.b64encode(json.dumps(secrets).encode()).decode()
119
+ resp = requests.post(
120
+ f"{_base_url(creds)}/{secret_id}/versions",
121
+ headers=_headers(creds),
122
+ json={"data": payload, "disable_previous": True},
123
+ timeout=10,
124
+ )
125
+ resp.raise_for_status()
126
+
127
+ # Invalidate cache
128
+ global _cache
129
+ _cache = None
130
+
131
+ return resp.json()["revision"]
oto/sops_secrets.py ADDED
@@ -0,0 +1,149 @@
1
+ """SOPS provider for oto secret resolution.
2
+
3
+ Lit un ou plusieurs fichiers YAML chiffrés via SOPS + age. Configurable via
4
+ `~/.otomata/config.yaml` :
5
+
6
+ - `sops_dir` : répertoire racine (préféré). Tous les `*.yaml` y sont décryptés
7
+ récursivement et mergés dans un seul dict plat. Défaut : `~/.otomata/secrets/`
8
+ (avec auto-détection si la struct contient un sous-dir avec `.sops.yaml`).
9
+ - `sops_file` : un seul fichier (legacy / mono-fichier). Si défini, prioritaire
10
+ sur `sops_dir` et seul ce fichier est lu.
11
+
12
+ Layout multi-fichiers (cf. AlexisLaporte/secrets) :
13
+ secrets/
14
+ ├── secrets.yaml # transverse Otomata
15
+ ├── tuls.yaml # host
16
+ ├── legacy.yaml # orphelins préservés
17
+ ├── missions/*.yaml # par mission/client
18
+ └── projects/*.yaml # projets internes
19
+
20
+ Décrypt délégué au CLI `sops` (déjà installé), résultat parsé en YAML plat,
21
+ caché module-level (une seule décryption par process).
22
+
23
+ Collisions de clés : même valeur ⇒ merge silencieux ; valeurs différentes
24
+ (clé générique scopée par fichier, ex. DATABASE_URL par projet) ⇒ la clé est
25
+ EXCLUE du namespace flat et listée dans `ambiguous_keys()` — `get_secret`
26
+ lève une erreur explicite si on la demande.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import subprocess
31
+ from pathlib import Path
32
+ from typing import Dict, Optional, List
33
+
34
+ _cache: Optional[Dict[str, str]] = None
35
+ # Clés définies avec des VALEURS DIFFÉRENTES dans plusieurs fichiers (clés
36
+ # génériques scopées par fichier : DATABASE_URL, DB_USER…). Elles sont exclues
37
+ # du namespace flat — les résoudre = lire le fichier scope directement.
38
+ _ambiguous: Dict[str, List[str]] = {}
39
+
40
+
41
+ def _candidate_default_dirs() -> List[Path]:
42
+ """Default candidates for the secrets root directory."""
43
+ home = Path.home()
44
+ return [
45
+ home / ".otomata" / "secrets",
46
+ ]
47
+
48
+
49
+ def _autodetect_dir() -> Optional[Path]:
50
+ """Find the first candidate dir that contains a .sops.yaml."""
51
+ for c in _candidate_default_dirs():
52
+ if (c / ".sops.yaml").is_file():
53
+ return c
54
+ return None
55
+
56
+
57
+ def _decrypt_file(path: Path) -> Dict[str, str]:
58
+ try:
59
+ decrypted = subprocess.run(
60
+ ["sops", "--decrypt", str(path)],
61
+ capture_output=True, text=True, check=True,
62
+ ).stdout
63
+ except FileNotFoundError as e:
64
+ raise RuntimeError(
65
+ "sops CLI not found. Install it: "
66
+ "https://github.com/getsops/sops/releases"
67
+ ) from e
68
+ except subprocess.CalledProcessError as e:
69
+ raise RuntimeError(
70
+ f"sops --decrypt failed for {path}. "
71
+ f"Make sure ~/.config/sops/age/keys.txt matches a recipient in .sops.yaml.\n"
72
+ f"stderr: {e.stderr}"
73
+ ) from e
74
+
75
+ import yaml
76
+ data = yaml.safe_load(decrypted) or {}
77
+ return {str(k): str(v) for k, v in data.items()}
78
+
79
+
80
+ def _merge(
81
+ target: Dict[str, str],
82
+ origins: Dict[str, str],
83
+ source: Dict[str, str],
84
+ source_path: Path,
85
+ ) -> None:
86
+ for k, v in source.items():
87
+ if k in _ambiguous:
88
+ _ambiguous[k].append(str(source_path))
89
+ continue
90
+ if k in target and target[k] != v:
91
+ # Valeurs divergentes ⇒ clé scopée par fichier, pas transverse.
92
+ # On la retire du flat ; get_secret lèvera si on la demande.
93
+ _ambiguous[k] = [origins.pop(k), str(source_path)]
94
+ del target[k]
95
+ continue
96
+ target[k] = v
97
+ origins[k] = str(source_path)
98
+
99
+
100
+ def ambiguous_keys() -> Dict[str, List[str]]:
101
+ """Clés exclues du namespace flat (valeurs divergentes), avec leurs fichiers."""
102
+ return _ambiguous
103
+
104
+
105
+ def fetch_secrets(
106
+ path: Optional[str] = None,
107
+ dir_path: Optional[str] = None,
108
+ ) -> Dict[str, str]:
109
+ """Decrypt all SOPS files and return a flat dict.
110
+
111
+ Args:
112
+ path: Legacy single-file mode. If set, only this file is read.
113
+ dir_path: Root directory. All `*.yaml` files (recursive) are decrypted
114
+ and merged. If None and `path` is None, autodetects.
115
+ """
116
+ global _cache
117
+ if _cache is not None:
118
+ return _cache
119
+
120
+ merged: Dict[str, str] = {}
121
+
122
+ if path:
123
+ single = Path(path).expanduser()
124
+ if not single.exists():
125
+ raise FileNotFoundError(f"SOPS secrets file not found at {single}.")
126
+ merged = _decrypt_file(single)
127
+ else:
128
+ root: Optional[Path] = Path(dir_path).expanduser() if dir_path else _autodetect_dir()
129
+ if root is None or not root.is_dir():
130
+ raise FileNotFoundError(
131
+ f"SOPS secrets dir not found. Tried: {[str(c) for c in _candidate_default_dirs()]}. "
132
+ f"Clone: git clone git@github.com:AlexisLaporte/secrets ~/.otomata/secrets"
133
+ )
134
+ yaml_files = sorted(p for p in root.rglob("*.yaml") if p.name != ".sops.yaml")
135
+ if not yaml_files:
136
+ raise FileNotFoundError(f"No .yaml files found under {root}.")
137
+ origins: Dict[str, str] = {}
138
+ for f in yaml_files:
139
+ _merge(merged, origins, _decrypt_file(f), f)
140
+
141
+ _cache = merged
142
+ return _cache
143
+
144
+
145
+ def invalidate_cache() -> None:
146
+ """Force a re-decrypt on next fetch (utile après `sops edit`)."""
147
+ global _cache
148
+ _cache = None
149
+ _ambiguous.clear()
oto/tools/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Otomata tools."""
@@ -0,0 +1,5 @@
1
+ """Anthropic Admin API client for usage and cost tracking."""
2
+
3
+ from .client import AnthropicAdminClient
4
+
5
+ __all__ = ["AnthropicAdminClient"]