caraer-cli 0.1.2__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 (83) hide show
  1. caraer_cli/__init__.py +4 -0
  2. caraer_cli/api/__init__.py +1 -0
  3. caraer_cli/api/apps.py +78 -0
  4. caraer_cli/api/auth.py +53 -0
  5. caraer_cli/api/client.py +179 -0
  6. caraer_cli/api/functions.py +72 -0
  7. caraer_cli/api/installation.py +93 -0
  8. caraer_cli/api/integration_runtime.py +77 -0
  9. caraer_cli/api/projects.py +213 -0
  10. caraer_cli/api/sandboxes.py +29 -0
  11. caraer_cli/api/webhooks.py +59 -0
  12. caraer_cli/app_sync.py +697 -0
  13. caraer_cli/apps_local.py +274 -0
  14. caraer_cli/commands/__init__.py +1 -0
  15. caraer_cli/commands/apps.py +2025 -0
  16. caraer_cli/commands/auth.py +173 -0
  17. caraer_cli/commands/company.py +68 -0
  18. caraer_cli/commands/installation.py +182 -0
  19. caraer_cli/commands/profile.py +239 -0
  20. caraer_cli/commands/publish.py +40 -0
  21. caraer_cli/commands/sandbox.py +92 -0
  22. caraer_cli/commands/skill.py +165 -0
  23. caraer_cli/commands/webhooks.py +68 -0
  24. caraer_cli/completion.py +77 -0
  25. caraer_cli/completion_callbacks.py +162 -0
  26. caraer_cli/context.py +41 -0
  27. caraer_cli/errors.py +84 -0
  28. caraer_cli/formatters/__init__.py +1 -0
  29. caraer_cli/formatters/output.py +381 -0
  30. caraer_cli/formatters/timestamps.py +98 -0
  31. caraer_cli/install_completion.py +38 -0
  32. caraer_cli/local_app.py +135 -0
  33. caraer_cli/main.py +104 -0
  34. caraer_cli/models/__init__.py +1 -0
  35. caraer_cli/models/api.py +52 -0
  36. caraer_cli/project/__init__.py +17 -0
  37. caraer_cli/project/app_bars_sync.py +154 -0
  38. caraer_cli/project/app_manifest_template.py +68 -0
  39. caraer_cli/project/inbound_sync.py +192 -0
  40. caraer_cli/project/json_schemas.py +66 -0
  41. caraer_cli/project/lifecycle_sync.py +119 -0
  42. caraer_cli/project/local_dev.py +825 -0
  43. caraer_cli/project/manifest_edit.py +51 -0
  44. caraer_cli/project/marketplace_assemble.py +165 -0
  45. caraer_cli/project/oauth_providers_sync.py +180 -0
  46. caraer_cli/project/paths.py +102 -0
  47. caraer_cli/project/pricing_sync.py +81 -0
  48. caraer_cli/project/push_plan.py +590 -0
  49. caraer_cli/project/release.py +128 -0
  50. caraer_cli/project/scaffold.py +537 -0
  51. caraer_cli/project/schedules_sync.py +170 -0
  52. caraer_cli/project/schema.py +160 -0
  53. caraer_cli/project/settings_sync.py +77 -0
  54. caraer_cli/project/state.py +51 -0
  55. caraer_cli/project/sync.py +308 -0
  56. caraer_cli/project/typegen.py +278 -0
  57. caraer_cli/project/validate_app.py +1215 -0
  58. caraer_cli/project/webhooks_sync.py +233 -0
  59. caraer_cli/resolve.py +55 -0
  60. caraer_cli/schemas/app.caraer.schema.json +175 -0
  61. caraer_cli/schemas/function.caraer.schema.json +29 -0
  62. caraer_cli/schemas/inbound.caraer.schema.json +49 -0
  63. caraer_cli/schemas/lifecycle.caraer.schema.json +50 -0
  64. caraer_cli/schemas/schedule.caraer.schema.json +39 -0
  65. caraer_cli/schemas/webhook.caraer.schema.json +50 -0
  66. caraer_cli/skills/.gitkeep +2 -0
  67. caraer_cli/skills/caraer-apps/SKILL.md +172 -0
  68. caraer_cli/skills/caraer-apps/reference.md +163 -0
  69. caraer_cli/state/__init__.py +1 -0
  70. caraer_cli/state/config.py +148 -0
  71. caraer_cli/state/session.py +98 -0
  72. caraer_cli/utils.py +40 -0
  73. caraer_cli/wizard/__init__.py +9 -0
  74. caraer_cli/wizard/catalog.py +170 -0
  75. caraer_cli/wizard/marketplace.py +350 -0
  76. caraer_cli/wizard/prompts.py +99 -0
  77. caraer_cli/wizard/public_app.py +519 -0
  78. caraer_cli/wizard/scope_macros.py +214 -0
  79. caraer_cli-0.1.2.dist-info/METADATA +266 -0
  80. caraer_cli-0.1.2.dist-info/RECORD +83 -0
  81. caraer_cli-0.1.2.dist-info/WHEEL +4 -0
  82. caraer_cli-0.1.2.dist-info/entry_points.txt +2 -0
  83. caraer_cli-0.1.2.dist-info/licenses/LICENSE +8 -0
@@ -0,0 +1,2 @@
1
+ # Packaged copy of repo skills/ (see hatch force-include). Placeholder so the
2
+ # package path exists before build; wheel build overlays skills/caraer-apps.
@@ -0,0 +1,172 @@
1
+ ---
2
+ name: caraer-apps
3
+ description: >-
4
+ Scaffolds, edits, validates, and deploys Caraer Apps V2 projects with the
5
+ caraer CLI (app.caraer.yaml, serverless functions, inbound routes, schedules,
6
+ lifecycle hooks, settings, app bars). Use when creating or changing a Caraer
7
+ public app, marketplace app, serverless function, webhook, inbound route, or
8
+ when the user mentions caraer apps, app.caraer.yaml, or caraer-cli.
9
+ ---
10
+
11
+ # Caraer Apps (CLI)
12
+
13
+ Build Caraer apps with the **caraer** CLI and the V2 layout (`platformVersion:
14
+ 2026.2`). Prefer CLI scaffolds over inventing folders by hand.
15
+
16
+ ## Install this skill
17
+
18
+ From a machine with `caraer-cli` installed:
19
+
20
+ ```bash
21
+ caraer skill install # ~/.cursor/skills/caraer-apps
22
+ caraer skill install --project # ./.cursor/skills/caraer-apps (this repo)
23
+ ```
24
+
25
+ Or copy `skills/caraer-apps/` from the [caraer-cli](https://github.com/Caraer-HQ/caraer-cli)
26
+ repo into `~/.cursor/skills/caraer-apps/`.
27
+
28
+ ## Preconditions
29
+
30
+ 1. CLI available: `caraer --version` (Python 3.10+).
31
+ 2. Auth + company: `caraer auth login` then `caraer company select <uuid>`.
32
+ 3. Work inside an app folder (has `caraer.json`) or pass `--file`.
33
+
34
+ ## Golden path
35
+
36
+ ```bash
37
+ caraer apps init --name my_app --label "My App"
38
+ cd my_app
39
+ # edit src/app/app.caraer.yaml + functions
40
+ caraer apps validate
41
+ caraer apps typegen
42
+ caraer apps push --dry-run
43
+ caraer apps push --deploy
44
+ caraer apps install
45
+ caraer apps status
46
+ caraer apps logs --follow
47
+ ```
48
+
49
+ Local loop without deploy:
50
+
51
+ ```bash
52
+ caraer apps dev
53
+ caraer apps test --function <name> --sample-only
54
+ ```
55
+
56
+ ## Project layout (V2)
57
+
58
+ ```text
59
+ caraer.json
60
+ src/app/
61
+ app.caraer.yaml # identity, auth, settings, pricing, app bars, OAuth
62
+ functions/<name>/ # function.caraer.json + index.js|main.py (+ shared.js)
63
+ lifecycle/*.json # install|uninstall|rotate|update → function
64
+ inbound/*.json # public HTTP → function
65
+ schedules/*.json # cron → function
66
+ webhooks/*.json # platform events → function
67
+ src/types/ # from `caraer apps typegen`
68
+ ```
69
+
70
+ JSON Schema for IDE hints:
71
+
72
+ `https://raw.githubusercontent.com/Caraer-HQ/caraer-cli/main/schemas/app.caraer.schema.json`
73
+
74
+ Point the YAML at it:
75
+
76
+ ```yaml
77
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/Caraer-HQ/caraer-cli/main/schemas/app.caraer.schema.json
78
+ ```
79
+
80
+ Reference example: `examples/webhook-inbox` in the caraer-cli repo.
81
+
82
+ ## Agent workflow
83
+
84
+ Copy and track:
85
+
86
+ ```text
87
+ App progress:
88
+ - [ ] Clarify job (inbound / schedule / record bar / OAuth / records)
89
+ - [ ] `caraer apps init` or open existing app
90
+ - [ ] Edit manifest settings (user-facing only)
91
+ - [ ] Add/edit functions (shared helpers copied per folder)
92
+ - [ ] Wire inbound / schedule / webhook / app bar / lifecycle
93
+ - [ ] `caraer apps validate` → fix until 0 errors
94
+ - [ ] `caraer apps push --dry-run` then `--deploy` when user asks
95
+ ```
96
+
97
+ ### Clarify before coding
98
+
99
+ Ask only what blocks design:
100
+
101
+ - Trigger: inbound HTTP, schedule, record event, app bar, or install lifecycle?
102
+ - Runtime: `nodejs22` (default) or `python312`?
103
+ - Need installation state/secrets? → `authMethod: API_KEY` (gets `installationToken`).
104
+ - Need Caraer user OAuth app install? → `OAUTH2` + redirect URIs.
105
+ - External provider (Google, etc.)? → `externalOAuthProviders` + `${ENV}` secrets.
106
+
107
+ ### Settings UX rules
108
+
109
+ Installation settings are for **admins installing the app**, not developers.
110
+
111
+ - Use clear labels + `helpText`. Prefer `SWITCH`, `OBJECT_SINGLE_SELECT`,
112
+ `SINGLE_SELECT` over free-text when possible.
113
+ - Do **not** add `caraer_api_base` or other platform URLs — runtime injects
114
+ `body.caraerApiBase`.
115
+ - Do **not** ask for object/property names as raw strings when a select type exists.
116
+ - Keep required settings to the minimum that makes the app work.
117
+
118
+ ### Functions
119
+
120
+ - Node: `exports.handler = async (req, res) => { ... }`.
121
+ - Python: `def handler(request): ...` returning `{statusCode, body}`.
122
+ - Each function folder deploys alone — **copy** `shared.js` / helpers into every
123
+ folder that needs them (see webhook-inbox).
124
+ - Read settings via flattened `body.settingsSchema` (`name` → `value`).
125
+ - Use `body.installationToken` + `body.appUuid` for
126
+ `/v2/apps/{appUuid}/installation/state|secrets|jobs`.
127
+ - Prefer `body.caraerApiBase` when calling Caraer APIs.
128
+
129
+ ### Scaffolding commands
130
+
131
+ | Need | Command |
132
+ |------|---------|
133
+ | New function | `caraer apps add-function` |
134
+ | Inbound route | `caraer apps add-inbound` |
135
+ | Schedule | `caraer apps add-schedule` |
136
+ | Webhook | `caraer apps add-webhook` |
137
+ | Setting | `caraer apps add-setting` |
138
+ | App bar | `caraer apps add-app-bar` |
139
+ | Lifecycle | `caraer apps add-lifecycle-hook` |
140
+ | Pricing | `caraer apps add-pricing-plan` |
141
+
142
+ ### Validate loop
143
+
144
+ After every structural edit:
145
+
146
+ ```bash
147
+ caraer apps validate --file .
148
+ ```
149
+
150
+ Fix errors before push. Warnings (e.g. missing inbound `sharedSecret`) are OK
151
+ to leave for the installer when documented in README.
152
+
153
+ ### Deploy safety
154
+
155
+ - Never push/deploy/install unless the user asks.
156
+ - Use `caraer apps push --dry-run` first for non-trivial changes.
157
+ - Do not commit secrets (`.env`, OAuth client secrets, inbound shared secrets).
158
+ - Expand `${ENV_VAR}` OAuth client fields from the **process environment** on push.
159
+
160
+ ## Anti-patterns
161
+
162
+ - Inventing V1 per-function GCP layouts when V2 is default.
163
+ - Hand-writing remote UUIDs into YAML (let push fill them).
164
+ - One giant shared package import across functions (runtime is per-folder).
165
+ - Putting developer-only knobs in `settingsSchema`.
166
+ - Skipping `validate` before `push`.
167
+
168
+ ## Dig deeper
169
+
170
+ - Payload shapes, lifecycle events, settings flattening: [reference.md](reference.md)
171
+ - CLI docs in the caraer-cli repo: `docs/app_lifecycle.md`, `docs/platform_versioning.md`
172
+ - Schemas: `schemas/*.caraer.schema.json`
@@ -0,0 +1,163 @@
1
+ # Caraer Apps — reference
2
+
3
+ Companion to [SKILL.md](SKILL.md). Read when implementing handlers or wiring
4
+ marketplace modules.
5
+
6
+ ## Auth methods
7
+
8
+ | `authMethod` | Use when | Notes |
9
+ |--------------|----------|-------|
10
+ | `API_KEY` | App needs `installationToken` for state/secrets/jobs | Typical for integrations |
11
+ | `OAUTH2` | Install uses Caraer OAuth app flow | Set `oauthRedirectUris` |
12
+
13
+ External providers (`externalOAuthProviders`) are separate from `authMethod`.
14
+ Connected tokens arrive as installation secrets (e.g. `gmail_access_token`).
15
+
16
+ ## Handler envelope (SERVERLESS)
17
+
18
+ Approximate JSON body (also wrapped as `req.body` for Node):
19
+
20
+ ```json
21
+ {
22
+ "event": "Installed",
23
+ "appUuid": "...",
24
+ "companyUuid": "...",
25
+ "installationToken": "...",
26
+ "caraerApiBase": "https://api.caraer.com/api",
27
+ "functionName": "catch",
28
+ "settingsSchema": [
29
+ { "name": "inbox_label", "type": "SINGLE_LINE", "value": "Main" }
30
+ ],
31
+ "secrets": {},
32
+ "payload": {}
33
+ }
34
+ ```
35
+
36
+ Lifecycle `event` values: `Installed`, `Updated`, `Uninstalled`, `Rotated`.
37
+
38
+ Connect / Disconnect for external OAuth are **not** lifecycle webhooks — read
39
+ secrets from the payload / secrets API after the user connects.
40
+
41
+ ## Installation APIs (Bearer installationToken)
42
+
43
+ Base: `{caraerApiBase}` (no trailing slash).
44
+
45
+ | Method | Path | Purpose |
46
+ |--------|------|---------|
47
+ | GET/PUT | `/v2/apps/{appUuid}/installation/state` | JSON blob state |
48
+ | GET/PUT | `/v2/apps/{appUuid}/installation/secrets` | Secret map |
49
+ | POST | `/v2/apps/{appUuid}/installation/jobs` | `{ "functionName", "payload" }` |
50
+
51
+ ## Setting field types
52
+
53
+ `SINGLE_LINE`, `MULTI_LINE`, `SINGLE_SELECT`, `MULTI_SELECT`,
54
+ `RECORD_SINGLE_SELECT`, `RECORD_MULTI_SELECT`, `OBJECT_SINGLE_SELECT`,
55
+ `OBJECT_MULTI_SELECT`, `PROPERTY_SINGLE_SELECT`, `PROPERTY_MULTI_SELECT`,
56
+ `SWITCH`, `MAPPING`, `SECRET`.
57
+
58
+ Flatten helper:
59
+
60
+ ```js
61
+ function flattenSettings(schema) {
62
+ const out = {};
63
+ for (const field of schema || []) {
64
+ if (field && field.name) {
65
+ out[field.name] = field.value ?? field.defaultValue ?? null;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ ```
71
+
72
+ `OBJECT_*_SELECT` values may be a string name or an object with `name` /
73
+ `internalName` — normalize before API calls.
74
+
75
+ ## Inbound routes
76
+
77
+ `src/app/inbound/<name>.json`:
78
+
79
+ ```json
80
+ {
81
+ "name": "catch",
82
+ "authMode": "SHARED_SECRET",
83
+ "enqueue": true,
84
+ "serverlessFunction": { "name": "catch" }
85
+ }
86
+ ```
87
+
88
+ Public URL shape (after deploy):
89
+
90
+ `POST /api/v2/public/apps/{appUuid}/inbound/{name}?companyUuid={companyUuid}`
91
+
92
+ Header: `X-Caraer-Inbound-Secret: <sharedSecret>`.
93
+
94
+ Set `sharedSecret` before production traffic; `validate` warns if missing.
95
+
96
+ ## Schedules
97
+
98
+ Six-field cron (sec min hour dom mon dow), example every 12 hours:
99
+
100
+ ```json
101
+ {
102
+ "name": "heartbeat",
103
+ "schedule": "0 0 */12 * * *",
104
+ "enabled": true,
105
+ "serverlessFunction": { "name": "heartbeat" }
106
+ }
107
+ ```
108
+
109
+ ## App bars
110
+
111
+ Action locations can use SERVERLESS without `iframeUrl`:
112
+
113
+ - `RECORD_PREVIEW`, `RECORD_OVERVIEW`, `RECORD_DETAIL`, `TOOL_BAR`, `TRAIT_BAR`
114
+
115
+ Wire `webhook.serverlessFunction.name` to a local function folder name.
116
+
117
+ ## Node handler skeleton
118
+
119
+ ```js
120
+ exports.handler = async (req, res) => {
121
+ try {
122
+ const body =
123
+ typeof req.body === "string"
124
+ ? JSON.parse(req.body || "{}")
125
+ : req.body || {};
126
+ // ...
127
+ return res.status(200).json({ ok: true });
128
+ } catch (err) {
129
+ return res.status(500).json({
130
+ ok: false,
131
+ error: String(err && err.message ? err.message : err),
132
+ });
133
+ }
134
+ };
135
+ ```
136
+
137
+ ## Python handler skeleton
138
+
139
+ ```python
140
+ def handler(request):
141
+ body = request.get("body") if isinstance(request, dict) else {}
142
+ if isinstance(body, str):
143
+ import json
144
+ body = json.loads(body or "{}")
145
+ return {"statusCode": 200, "body": {"ok": True}}
146
+ ```
147
+
148
+ ## Useful CLI
149
+
150
+ ```bash
151
+ caraer apps current
152
+ caraer apps validate
153
+ caraer apps push --dry-run
154
+ caraer apps push --deploy
155
+ caraer apps status
156
+ caraer apps logs --follow
157
+ caraer apps state get
158
+ caraer apps jobs list
159
+ caraer apps connections list
160
+ caraer apps test --function <name> --record <uuid>
161
+ caraer apps dev --invoke-schedule <schedule-name>
162
+ caraer skill install --project
163
+ ```
@@ -0,0 +1 @@
1
+ """Configuration and session helpers."""
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from platformdirs import user_config_dir
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else:
14
+ import tomli as tomllib
15
+
16
+
17
+ CONFIG_DIRNAME = "caraer-cli"
18
+ CONFIG_FILENAME = "config.toml"
19
+
20
+
21
+ @dataclass
22
+ class ProfileConfig:
23
+ base_url: str
24
+ company_uuid: str | None = None
25
+ sandbox_uuid: str | None = None
26
+ app_uuid: str | None = None
27
+ app_file: str | None = None
28
+ output: str = "table"
29
+ timeout_seconds: float = 120.0
30
+ verify_ssl: bool = True
31
+
32
+
33
+ @dataclass
34
+ class CliConfig:
35
+ active_profile: str = "dev"
36
+ profiles: dict[str, ProfileConfig] = field(default_factory=dict)
37
+
38
+ @staticmethod
39
+ def default() -> "CliConfig":
40
+ return CliConfig(
41
+ active_profile="dev",
42
+ profiles={
43
+ "dev": ProfileConfig(base_url="http://localhost:8080"),
44
+ "staging": ProfileConfig(base_url="https://staging.caraer.com"),
45
+ "prod": ProfileConfig(base_url="https://caraer.com"),
46
+ },
47
+ )
48
+
49
+
50
+ def config_dir() -> Path:
51
+ return Path(user_config_dir(CONFIG_DIRNAME))
52
+
53
+
54
+ def config_path() -> Path:
55
+ return config_dir() / CONFIG_FILENAME
56
+
57
+
58
+ def _toml_value(value: Any) -> str:
59
+ if isinstance(value, str):
60
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
61
+ return f'"{escaped}"'
62
+ if isinstance(value, bool):
63
+ return "true" if value else "false"
64
+ if value is None:
65
+ return '""'
66
+ return str(value)
67
+
68
+
69
+ def _to_toml(cfg: CliConfig) -> str:
70
+ lines: list[str] = [f'active_profile = "{cfg.active_profile}"', ""]
71
+ for profile_name, profile in cfg.profiles.items():
72
+ lines.extend(
73
+ [
74
+ f"[profiles.{profile_name}]",
75
+ f"base_url = {_toml_value(profile.base_url)}",
76
+ f"company_uuid = {_toml_value(profile.company_uuid)}",
77
+ f"sandbox_uuid = {_toml_value(profile.sandbox_uuid)}",
78
+ f"app_uuid = {_toml_value(profile.app_uuid)}",
79
+ f"app_file = {_toml_value(profile.app_file)}",
80
+ f"output = {_toml_value(profile.output)}",
81
+ f"timeout_seconds = {_toml_value(profile.timeout_seconds)}",
82
+ f"verify_ssl = {_toml_value(profile.verify_ssl)}",
83
+ "",
84
+ ]
85
+ )
86
+ return "\n".join(lines).strip() + "\n"
87
+
88
+
89
+ def load_config() -> CliConfig:
90
+ path = config_path()
91
+ if not path.exists():
92
+ cfg = CliConfig.default()
93
+ save_config(cfg)
94
+ return cfg
95
+
96
+ with path.open("rb") as handle:
97
+ raw = tomllib.load(handle)
98
+
99
+ cfg = CliConfig.default()
100
+ cfg.active_profile = raw.get("active_profile", cfg.active_profile)
101
+ profile_block = raw.get("profiles", {})
102
+ if isinstance(profile_block, dict):
103
+ cfg.profiles = {}
104
+ for name, value in profile_block.items():
105
+ if not isinstance(value, dict):
106
+ continue
107
+ cfg.profiles[name] = ProfileConfig(
108
+ base_url=str(value.get("base_url", "http://localhost:8080")),
109
+ company_uuid=value.get("company_uuid") or None,
110
+ sandbox_uuid=value.get("sandbox_uuid") or None,
111
+ app_uuid=value.get("app_uuid") or None,
112
+ app_file=value.get("app_file") or None,
113
+ output=str(value.get("output", "table")),
114
+ timeout_seconds=float(value.get("timeout_seconds", 120.0)),
115
+ verify_ssl=bool(value.get("verify_ssl", True)),
116
+ )
117
+
118
+ if cfg.active_profile not in cfg.profiles:
119
+ cfg.active_profile = next(iter(cfg.profiles.keys()), "dev")
120
+ return cfg
121
+
122
+
123
+ def save_config(cfg: CliConfig) -> None:
124
+ dir_path = config_dir()
125
+ dir_path.mkdir(parents=True, exist_ok=True)
126
+ path = config_path()
127
+ path.write_text(_to_toml(cfg), encoding="utf-8")
128
+ os.chmod(path, 0o600)
129
+
130
+
131
+ def active_profile(cfg: CliConfig, override: str | None = None) -> tuple[str, ProfileConfig]:
132
+ key = override or cfg.active_profile
133
+ if key not in cfg.profiles:
134
+ raise ValueError(f"Unknown profile '{key}'.")
135
+ return key, cfg.profiles[key]
136
+
137
+
138
+ def profile_as_dict(profile: ProfileConfig) -> dict[str, Any]:
139
+ return {
140
+ "base_url": profile.base_url,
141
+ "company_uuid": profile.company_uuid,
142
+ "sandbox_uuid": profile.sandbox_uuid,
143
+ "app_uuid": profile.app_uuid,
144
+ "app_file": profile.app_file,
145
+ "output": profile.output,
146
+ "timeout_seconds": profile.timeout_seconds,
147
+ "verify_ssl": profile.verify_ssl,
148
+ }
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import keyring
9
+ from platformdirs import user_config_dir
10
+
11
+
12
+ APP_NAME = "caraer-cli"
13
+ SERVICE_NAME = "caraer-cli-session"
14
+
15
+
16
+ def _fallback_session_path() -> Path:
17
+ path = Path(user_config_dir(APP_NAME)) / "sessions.json"
18
+ path.parent.mkdir(parents=True, exist_ok=True)
19
+ if not path.exists():
20
+ path.write_text("{}", encoding="utf-8")
21
+ os.chmod(path, 0o600)
22
+ return path
23
+
24
+
25
+ def _load_fallback() -> dict[str, Any]:
26
+ path = _fallback_session_path()
27
+ try:
28
+ return json.loads(path.read_text(encoding="utf-8"))
29
+ except json.JSONDecodeError:
30
+ return {}
31
+
32
+
33
+ def _save_fallback(data: dict[str, Any]) -> None:
34
+ path = _fallback_session_path()
35
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
36
+ os.chmod(path, 0o600)
37
+
38
+
39
+ def get_session_token(profile: str) -> str | None:
40
+ try:
41
+ token = keyring.get_password(SERVICE_NAME, profile)
42
+ if token:
43
+ return token
44
+ except Exception:
45
+ pass
46
+
47
+ data = _load_fallback()
48
+ value = data.get(profile, {})
49
+ token = value.get("token")
50
+ return str(token) if token else None
51
+
52
+
53
+ def get_refresh_token(profile: str) -> str | None:
54
+ data = _load_fallback()
55
+ value = data.get(profile, {})
56
+ if not isinstance(value, dict):
57
+ return None
58
+ token = value.get("refreshToken")
59
+ return str(token) if token else None
60
+
61
+
62
+ def set_session_token(
63
+ profile: str,
64
+ token: str,
65
+ *,
66
+ refresh_token: str | None = None,
67
+ ) -> None:
68
+ wrote_keyring = False
69
+ try:
70
+ keyring.set_password(SERVICE_NAME, profile, token)
71
+ wrote_keyring = True
72
+ except Exception:
73
+ wrote_keyring = False
74
+
75
+ # Always persist tokens in the fallback file so refreshToken survives
76
+ # even when the access token is stored in the OS keyring.
77
+ data = _load_fallback()
78
+ data.setdefault(profile, {})
79
+ if not isinstance(data[profile], dict):
80
+ data[profile] = {}
81
+ data[profile]["token"] = token
82
+ if refresh_token is not None:
83
+ data[profile]["refreshToken"] = refresh_token
84
+ if not wrote_keyring or refresh_token is not None:
85
+ _save_fallback(data)
86
+
87
+
88
+ def clear_session_token(profile: str) -> None:
89
+ try:
90
+ keyring.delete_password(SERVICE_NAME, profile)
91
+ except Exception:
92
+ pass
93
+
94
+ data = _load_fallback()
95
+ if profile in data and isinstance(data[profile], dict):
96
+ data[profile].pop("token", None)
97
+ data[profile].pop("refreshToken", None)
98
+ _save_fallback(data)
caraer_cli/utils.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ def load_structured_file(path: str) -> dict[str, Any]:
11
+ content = Path(path).read_text(encoding="utf-8")
12
+ suffix = Path(path).suffix.lower()
13
+ if suffix in (".yaml", ".yml"):
14
+ payload = yaml.safe_load(content)
15
+ else:
16
+ payload = json.loads(content)
17
+ if not isinstance(payload, dict):
18
+ raise ValueError("Input file must contain a JSON/YAML object at the root.")
19
+ return payload
20
+
21
+
22
+ def parse_patch(value: str) -> dict[str, Any]:
23
+ parsed = json.loads(value)
24
+ if not isinstance(parsed, dict):
25
+ raise ValueError("Patch must be a JSON object.")
26
+ return parsed
27
+
28
+
29
+ def deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
30
+ merged = dict(base)
31
+ for key, value in patch.items():
32
+ if (
33
+ key in merged
34
+ and isinstance(merged[key], dict)
35
+ and isinstance(value, dict)
36
+ ):
37
+ merged[key] = deep_merge(merged[key], value)
38
+ else:
39
+ merged[key] = value
40
+ return merged
@@ -0,0 +1,9 @@
1
+ """Interactive CLI wizards."""
2
+
3
+ __all__ = ["run_public_app_wizard"]
4
+
5
+
6
+ def run_public_app_wizard(*args, **kwargs):
7
+ from caraer_cli.wizard.public_app import run_public_app_wizard as _run
8
+
9
+ return _run(*args, **kwargs)