doushi-cli 0.1.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.
@@ -0,0 +1,226 @@
1
+ """Prediction and inference command for Doushi CLI (predict)."""
2
+
3
+ import csv
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+ import typer
9
+ from rich.table import Table
10
+
11
+ from doushi.client import DoushiClient, DoushiAPIError
12
+ from doushi.ui import (
13
+ console,
14
+ err_console,
15
+ print_error_panel,
16
+ print_json,
17
+ print_success,
18
+ )
19
+
20
+ predict_app = typer.Typer(help="Run live inference on trained Doushi AI models.")
21
+
22
+
23
+ def load_batch_file(file_path: Path) -> List[Dict[str, Any]]:
24
+ """Load sample records from various file formats (.json, .parquet, .xls, .xlsx, .csv, .tsv)."""
25
+ ext = file_path.suffix.lower()
26
+
27
+ # 1. JSON
28
+ if ext == ".json":
29
+ with open(file_path, "r", encoding="utf-8") as f:
30
+ data = json.load(f)
31
+ if isinstance(data, list):
32
+ return data
33
+ elif isinstance(data, dict):
34
+ return [data]
35
+ raise ValueError("JSON file must contain an array of sample objects or a single sample object.")
36
+
37
+ # 2. Parquet
38
+ elif ext == ".parquet":
39
+ try:
40
+ import pandas as pd
41
+ df = pd.read_parquet(file_path)
42
+ return df.to_dict(orient="records")
43
+ except ImportError:
44
+ try:
45
+ import pyarrow.parquet as pq
46
+ table = pq.read_table(file_path)
47
+ return table.to_pylist()
48
+ except ImportError:
49
+ raise ImportError(
50
+ "To read .parquet files, please install pandas or pyarrow: pip install pandas pyarrow"
51
+ )
52
+
53
+ # 3. Excel (.xls, .xlsx)
54
+ elif ext in [".xls", ".xlsx"]:
55
+ try:
56
+ import pandas as pd
57
+ df = pd.read_excel(file_path)
58
+ return df.to_dict(orient="records")
59
+ except ImportError:
60
+ raise ImportError(
61
+ "To read Excel files (.xls, .xlsx), please install pandas and openpyxl: pip install pandas openpyxl"
62
+ )
63
+
64
+ # 4. Delimited Text (CSV, TSV, TXT)
65
+ else:
66
+ delimiter = "\t" if ext == ".tsv" else ","
67
+ samples: List[Dict[str, Any]] = []
68
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
69
+ sample_header = f.read(2048)
70
+ f.seek(0)
71
+ if ext != ".tsv" and ";" in sample_header and "," not in sample_header:
72
+ delimiter = ";"
73
+
74
+ reader = csv.DictReader(f, delimiter=delimiter)
75
+ for row in reader:
76
+ parsed_row = {}
77
+ for k, v in row.items():
78
+ if v is None or v == "":
79
+ parsed_row[k] = None
80
+ else:
81
+ try:
82
+ parsed_row[k] = float(v) if "." in v else int(v)
83
+ except ValueError:
84
+ parsed_row[k] = v
85
+ samples.append(parsed_row)
86
+ return samples
87
+
88
+
89
+ @predict_app.command(name="predict")
90
+ def predict(
91
+ project_id: str = typer.Argument(..., help="Unique ID of the trained project"),
92
+ data: Optional[str] = typer.Option(
93
+ None,
94
+ "--data",
95
+ "-d",
96
+ help="JSON string of feature inputs (e.g. '{\"age\": 30, \"tenure\": 12}')",
97
+ ),
98
+ file_path: Optional[Path] = typer.Option(
99
+ None,
100
+ "--file",
101
+ "-f",
102
+ help="Path to CSV, JSON, Parquet, or Excel file containing batch samples for inference",
103
+ exists=True,
104
+ file_okay=True,
105
+ dir_okay=False,
106
+ ),
107
+ output: Optional[Path] = typer.Option(
108
+ None,
109
+ "--output",
110
+ "-o",
111
+ help="Path to save prediction outputs (.csv or .json)",
112
+ ),
113
+ json_output: bool = typer.Option(
114
+ False,
115
+ "--json",
116
+ help="Output predictions as raw JSON",
117
+ ),
118
+ ) -> None:
119
+ """Run live prediction using inline JSON, a CSV/JSON/Parquet/Excel file, or piped stdin."""
120
+ client = DoushiClient()
121
+ client.check_auth_or_exit()
122
+
123
+ payload_data = None
124
+
125
+ # 1. Check direct CLI JSON argument
126
+ if data:
127
+ try:
128
+ payload_data = json.loads(data)
129
+ except json.JSONDecodeError:
130
+ print_error_panel(
131
+ "Invalid JSON",
132
+ f"Could not parse --data as valid JSON: {data}",
133
+ remedy="Ensure proper quoting: --data '{\"feature_1\": 10, \"feature_2\": 20}'"
134
+ )
135
+ raise typer.Exit(code=1)
136
+
137
+ # 2. Check file input
138
+ elif file_path:
139
+ try:
140
+ payload_data = load_batch_file(file_path)
141
+ except Exception as e:
142
+ print_error_panel("File Loading Error", str(e))
143
+ raise typer.Exit(code=1)
144
+
145
+ # 3. Check stdin pipe
146
+ elif not sys.stdin.isatty():
147
+ stdin_content = sys.stdin.read().strip()
148
+ if stdin_content:
149
+ try:
150
+ payload_data = json.loads(stdin_content)
151
+ except json.JSONDecodeError:
152
+ # Try parsing as CSV
153
+ import io
154
+ reader = csv.DictReader(io.StringIO(stdin_content))
155
+ samples = [row for row in reader]
156
+ if samples:
157
+ payload_data = samples
158
+ else:
159
+ print_error_panel("Invalid Stdin Input", "Could not parse standard input as JSON or CSV.")
160
+ raise typer.Exit(code=1)
161
+
162
+ if payload_data is None:
163
+ print_error_panel(
164
+ "Missing Input Data",
165
+ "No prediction data provided.",
166
+ remedy="Provide data using:\n • --data '{\"feature\": 12}'\n • --file test_data.csv\n • cat test_data.csv | doushi predict <id>"
167
+ )
168
+ raise typer.Exit(code=1)
169
+
170
+ # Execute Prediction
171
+ is_interactive = sys.stdout.isatty() and not json_output
172
+ if is_interactive:
173
+ with console.status(f"[bold cyan]Computing prediction for project {project_id}...[/bold cyan]"):
174
+ try:
175
+ result = client.predict(project_id, payload_data)
176
+ except DoushiAPIError as e:
177
+ print_error_panel("Inference Failed", e.message, status_code=e.status_code)
178
+ raise typer.Exit(code=1)
179
+ else:
180
+ try:
181
+ result = client.predict(project_id, payload_data)
182
+ except DoushiAPIError as e:
183
+ err_console.print(f"Error: {e.message}")
184
+ raise typer.Exit(code=1)
185
+
186
+ # Output handling
187
+ if output:
188
+ if output.suffix.lower() == ".json":
189
+ with open(output, "w", encoding="utf-8") as f:
190
+ json.dump(result, f, indent=2)
191
+ else:
192
+ # Write CSV
193
+ predictions = result.get("prediction") or result.get("predictions")
194
+ if isinstance(predictions, list):
195
+ with open(output, "w", encoding="utf-8", newline="") as f:
196
+ writer = csv.writer(f)
197
+ writer.writerow(["prediction"])
198
+ for p in predictions:
199
+ writer.writerow([p])
200
+ else:
201
+ with open(output, "w", encoding="utf-8") as f:
202
+ f.write(str(predictions))
203
+ print_success(f"Predictions saved to {output}")
204
+ return
205
+
206
+ if json_output or not sys.stdout.isatty():
207
+ print_json(result)
208
+ return
209
+
210
+ # Formatted Rich Output
211
+ pred = result.get("prediction") if "prediction" in result else result.get("predictions")
212
+ latency = result.get("latency_ms", "-")
213
+
214
+ table = Table(title=f"Prediction Result (Latency: {latency}ms)", border_style="green")
215
+ table.add_column("Sample #", style="dim", justify="right")
216
+ table.add_column("Prediction", style="bold green")
217
+
218
+ if isinstance(pred, list):
219
+ for i, val in enumerate(pred[:50]):
220
+ table.add_row(str(i + 1), str(val))
221
+ if len(pred) > 50:
222
+ table.add_row("...", f"[dim]and {len(pred) - 50} more samples[/dim]")
223
+ else:
224
+ table.add_row("1", str(pred))
225
+
226
+ console.print(table)
@@ -0,0 +1,125 @@
1
+ """Project management commands for Doushi CLI (list, view, delete, init)."""
2
+
3
+ from typing import Optional
4
+ import typer
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+ from rich.prompt import Prompt, Confirm
8
+
9
+ from doushi.client import DoushiClient, DoushiAPIError
10
+ from doushi.ui import (
11
+ console,
12
+ err_console,
13
+ print_error_panel,
14
+ print_info,
15
+ print_json,
16
+ print_projects_table,
17
+ print_success,
18
+ print_warning,
19
+ )
20
+
21
+ projects_app = typer.Typer(help="Manage and inspect your Doushi AI projects.")
22
+
23
+
24
+ @projects_app.command(name="list")
25
+ @projects_app.command(name="ls")
26
+ def list_projects(
27
+ json_output: bool = typer.Option(False, "--json", help="Output list as JSON"),
28
+ ) -> None:
29
+ """List all AI projects in your organization."""
30
+ client = DoushiClient()
31
+ with console.status("[bold cyan]Fetching projects...[/bold cyan]"):
32
+ try:
33
+ projects = client.list_projects()
34
+ except DoushiAPIError as e:
35
+ print_error_panel("Failed to List Projects", e.message, status_code=e.status_code)
36
+ raise typer.Exit(code=1)
37
+
38
+ if json_output:
39
+ print_json(projects)
40
+ return
41
+
42
+ print_projects_table(projects)
43
+
44
+
45
+ @projects_app.command(name="view")
46
+ def view_project(
47
+ project_id: str = typer.Argument(..., help="Unique ID of the project"),
48
+ json_output: bool = typer.Option(False, "--json", help="Output details as JSON"),
49
+ ) -> None:
50
+ """View detailed metrics, hyperparameters, and status for a project."""
51
+ client = DoushiClient()
52
+ with console.status(f"[bold cyan]Fetching project {project_id}...[/bold cyan]"):
53
+ try:
54
+ p = client.get_project(project_id)
55
+ except DoushiAPIError as e:
56
+ print_error_panel("Project Not Found", e.message, status_code=e.status_code)
57
+ raise typer.Exit(code=1)
58
+
59
+ if json_output:
60
+ print_json(p)
61
+ return
62
+
63
+ status = str(p.get("status", "unknown")).upper()
64
+ name = p.get("name", "Untitled")
65
+
66
+ console.print(f"\n[bold white]Project:[/bold white] [bold cyan]{name}[/bold cyan] ([dim]{project_id}[/dim])")
67
+ console.print(f"[bold white]Status:[/bold white] [bold]{status}[/bold]\n")
68
+
69
+ # Overview table
70
+ table = Table(title="Project Details", border_style="dim")
71
+ table.add_column("Property", style="bold white")
72
+ table.add_column("Value", style="cyan")
73
+
74
+ table.add_row("ID", str(p.get("id", "-")))
75
+ table.add_row("Name", name)
76
+ table.add_row("Status", status)
77
+ table.add_row("Prompt Goal", str(p.get("prompt", "-")))
78
+ table.add_row("Dataset Path", str(p.get("dataset_path", "-")))
79
+ table.add_row("Model Provider", str(p.get("provider", "gemini")))
80
+ table.add_row("Current Iteration", str(p.get("current_iteration", 0)))
81
+ table.add_row("Created At", str(p.get("created_at", "-")))
82
+
83
+ console.print(table)
84
+
85
+ # Metrics section
86
+ metrics = p.get("metrics")
87
+ if metrics and isinstance(metrics, dict):
88
+ m_table = Table(title="Model Evaluation Metrics", border_style="green")
89
+ m_table.add_column("Metric", style="bold white")
90
+ m_table.add_column("Score", style="bold green")
91
+ for k, v in metrics.items():
92
+ if isinstance(v, (int, float)):
93
+ m_table.add_row(k.upper(), f"{v:.4f}")
94
+ else:
95
+ m_table.add_row(k.upper(), str(v))
96
+ console.print(m_table)
97
+
98
+ # Error section
99
+ error = p.get("error")
100
+ if error:
101
+ print_error_panel("Model Failure Error", str(error))
102
+
103
+
104
+ @projects_app.command(name="delete")
105
+ def delete_project(
106
+ project_id: str = typer.Argument(..., help="Unique ID of the project to delete"),
107
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
108
+ ) -> None:
109
+ """Delete a project and its associated artifacts."""
110
+ client = DoushiClient()
111
+
112
+ if not yes:
113
+ confirm = Confirm.ask(f"[bold yellow]Are you sure you want to permanently delete project '{project_id}'?[/bold yellow]")
114
+ if not confirm:
115
+ print_info("Deletion cancelled.")
116
+ return
117
+
118
+ with console.status(f"[bold red]Deleting project {project_id}...[/bold red]"):
119
+ try:
120
+ client.delete_project(project_id)
121
+ except DoushiAPIError as e:
122
+ print_error_panel("Deletion Failed", e.message, status_code=e.status_code)
123
+ raise typer.Exit(code=1)
124
+
125
+ print_success(f"Project '{project_id}' successfully deleted.")
@@ -0,0 +1,198 @@
1
+ """Model training and creation command for Doushi CLI (train / create)."""
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ import typer
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+ from rich.prompt import Prompt
12
+
13
+ from doushi.client import DoushiClient, DoushiAPIError
14
+ from doushi.ui import (
15
+ console,
16
+ err_console,
17
+ print_banner,
18
+ print_error_panel,
19
+ print_info,
20
+ print_json,
21
+ print_success,
22
+ print_warning,
23
+ )
24
+
25
+ train_app = typer.Typer(help="Train and build autonomous ML models from datasets.")
26
+
27
+
28
+ @train_app.command(name="train")
29
+ @train_app.command(name="create")
30
+ def train(
31
+ dataset_file: Path = typer.Argument(
32
+ ...,
33
+ help="Path to local dataset file (.csv, .parquet, .xlsx, .tsv, .json)",
34
+ exists=True,
35
+ file_okay=True,
36
+ dir_okay=False,
37
+ readable=True,
38
+ ),
39
+ goal: Optional[str] = typer.Option(
40
+ None,
41
+ "--goal",
42
+ "-g",
43
+ help="Natural language prediction goal (e.g. 'Predict customer churn probability')",
44
+ ),
45
+ name: Optional[str] = typer.Option(
46
+ None,
47
+ "--name",
48
+ "-n",
49
+ help="Project name (defaults to dataset filename)",
50
+ ),
51
+ follow: bool = typer.Option(
52
+ True,
53
+ "--follow/--no-follow",
54
+ "-f/-F",
55
+ help="Stream agent progress until model training completes.",
56
+ ),
57
+ json_output: bool = typer.Option(
58
+ False,
59
+ "--json",
60
+ help="Output created project details as JSON",
61
+ ),
62
+ ) -> None:
63
+ """Upload dataset and train an autonomous ML model with agent self-healing."""
64
+ client = DoushiClient()
65
+ client.check_auth_or_exit()
66
+
67
+ # 1. Fetch user account info for tier limit verification
68
+ with console.status("[bold cyan]Checking account quotas & tier limits...[/bold cyan]"):
69
+ try:
70
+ whoami_data = client.get_whoami()
71
+ org = whoami_data.get("organization") or {}
72
+ tier = str(org.get("tier", "free")).lower()
73
+ except Exception:
74
+ tier = "free"
75
+
76
+ # 2. Local Pre-flight check on dataset size
77
+ client.check_file_limits(dataset_file, tier=tier)
78
+
79
+ # 3. Prompt for goal if missing
80
+ if not goal:
81
+ print_banner()
82
+ console.print(f"\n[bold white]Dataset:[/bold white] [bold cyan]{dataset_file.name}[/bold cyan]")
83
+ console.print(f"[bold white]Size:[/bold white] [dim]{dataset_file.stat().st_size / (1024*1024):.2f} MB[/dim]\n")
84
+ goal = Prompt.ask(
85
+ "[bold green]🎯 What is your prediction goal for this dataset?[/bold green]\n"
86
+ "[dim](e.g. 'Predict customer churn', 'Forecast monthly revenue', 'Classify spam')[/dim]"
87
+ )
88
+ if not goal or not goal.strip():
89
+ print_error_panel("Goal Required", "A prediction goal is required for the autonomous agent to build the model.")
90
+ raise typer.Exit(code=1)
91
+
92
+ project_name = name or dataset_file.stem.replace("_", " ").replace("-", " ").title()
93
+
94
+ # 4. Create Project
95
+ with console.status(f"[bold cyan]Initializing project '{project_name}'...[/bold cyan]"):
96
+ try:
97
+ proj_data = client.create_project(name=project_name)
98
+ project_id = proj_data.get("project_id") or proj_data.get("id")
99
+ except DoushiAPIError as e:
100
+ print_error_panel("Project Creation Failed", e.message, status_code=e.status_code)
101
+ raise typer.Exit(code=1)
102
+
103
+ # 5. Request S3 presigned URL
104
+ file_size = dataset_file.stat().st_size
105
+ with console.status("[bold cyan]Requesting secure storage upload URL...[/bold cyan]"):
106
+ try:
107
+ upload_info = client.request_upload_url(
108
+ project_id=project_id,
109
+ filename=dataset_file.name,
110
+ file_size=file_size,
111
+ )
112
+ presigned_url = upload_info["url"]
113
+ except DoushiAPIError as e:
114
+ print_error_panel("Upload Request Failed", e.message, status_code=e.status_code)
115
+ raise typer.Exit(code=1)
116
+
117
+ # 6. Stream upload file to S3
118
+ client.upload_file_to_s3(presigned_url, dataset_file)
119
+ print_success(f"Dataset '{dataset_file.name}' uploaded successfully.")
120
+
121
+ # 7. Start Agent Pipeline
122
+ with console.status("[bold cyan]Dispatching autonomous agent pipeline...[/bold cyan]"):
123
+ try:
124
+ client.start_pipeline(project_id=project_id, prompt=goal)
125
+ except DoushiAPIError as e:
126
+ print_error_panel("Agent Dispatch Failed", e.message, status_code=e.status_code)
127
+ raise typer.Exit(code=1)
128
+
129
+ print_success(f"Autonomous agent started for project [bold cyan]{project_id}[/bold cyan].")
130
+
131
+ if json_output:
132
+ print_json({
133
+ "project_id": project_id,
134
+ "name": project_name,
135
+ "goal": goal,
136
+ "dataset": dataset_file.name,
137
+ "status": "queued"
138
+ })
139
+ return
140
+
141
+ if not follow:
142
+ console.print(f"\n👉 Track status: [bold cyan]doushi view {project_id}[/bold cyan]")
143
+ console.print(f"👉 Stream logs: [bold cyan]doushi logs {project_id} -f[/bold cyan]\n")
144
+ return
145
+
146
+ # 8. Follow / Stream agent progress
147
+ console.print("\n[bold white]Watching Autonomous Agent Execution[/bold white] [dim](Press Ctrl+C to detach)[/dim]\n")
148
+
149
+ last_status = None
150
+ with console.status("[bold yellow]Agent analyzing dataset schema & selecting ML strategy...[/bold yellow]") as status_spinner:
151
+ for _ in range(120): # Wait up to 10 minutes
152
+ time.sleep(5)
153
+ try:
154
+ p = client.get_project(project_id)
155
+ except Exception:
156
+ continue
157
+
158
+ current_status = str(p.get("status", "pending")).lower()
159
+
160
+ if current_status != last_status:
161
+ last_status = current_status
162
+ if current_status in ["queued", "pending"]:
163
+ status_spinner.update("[bold blue]Job queued in compute worker...[/bold blue]")
164
+ elif current_status in ["running", "training"]:
165
+ status_spinner.update("[bold yellow]Agent executing sandbox code & optimizing hyperparameters...[/bold yellow]")
166
+ elif current_status == "success":
167
+ break
168
+ elif current_status == "failed":
169
+ break
170
+
171
+ if current_status == "success":
172
+ console.print("\n")
173
+ print_success(f"Model successfully trained and ready for inference!")
174
+
175
+ metrics = p.get("metrics") or {}
176
+ if isinstance(metrics, dict) and metrics:
177
+ m_table = Table(title="Model Evaluation Performance", border_style="green")
178
+ m_table.add_column("Metric", style="bold white")
179
+ m_table.add_column("Score", style="bold green")
180
+ for k, v in metrics.items():
181
+ val_str = f"{v:.4f}" if isinstance(v, (int, float)) else str(v)
182
+ m_table.add_row(k.upper(), val_str)
183
+ console.print(m_table)
184
+
185
+ console.print("\n[bold green]✦ Next Steps:[/bold green]")
186
+ console.print(f" • Run live prediction: [bold cyan]doushi predict {project_id} --data '{{...}}'[/bold cyan]")
187
+ console.print(f" • Export model & code: [bold cyan]doushi export {project_id} --out ./model-bundle/[/bold cyan]")
188
+ console.print(f" • Inspect project: [bold cyan]doushi view {project_id}[/bold cyan]\n")
189
+
190
+ elif current_status == "failed":
191
+ console.print("\n")
192
+ err_msg = p.get("error") or "Unknown error occurred during agent training."
193
+ print_error_panel(
194
+ title="Model Training Failed",
195
+ message=str(err_msg),
196
+ remedy=f"View full agent traceback with: doushi logs {project_id}"
197
+ )
198
+ raise typer.Exit(code=1)
doushi/config.py ADDED
@@ -0,0 +1,111 @@
1
+ """Configuration and credential management for Doushi CLI."""
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional
8
+
9
+ DEFAULT_API_URL = "https://api.doushi.ai"
10
+ DEFAULT_DASHBOARD_URL = "https://doushi.ai"
11
+
12
+ DOUSHI_DIR = Path.home() / ".doushi"
13
+ CREDENTIALS_FILE = DOUSHI_DIR / "credentials"
14
+ CONFIG_FILE = DOUSHI_DIR / "config.json"
15
+
16
+
17
+ def ensure_doushi_dir() -> None:
18
+ """Ensure ~/.doushi directory exists with secure permissions."""
19
+ DOUSHI_DIR.mkdir(parents=True, exist_ok=True)
20
+ try:
21
+ os.chmod(DOUSHI_DIR, stat.S_IRWXU) # 0700
22
+ except Exception:
23
+ pass
24
+
25
+
26
+ def get_api_url() -> str:
27
+ """Get active API endpoint from env, config file, or default."""
28
+ if os.getenv("DOUSHI_API_URL"):
29
+ return os.environ["DOUSHI_API_URL"].rstrip("/")
30
+ config = load_config()
31
+ return config.get("api_url", DEFAULT_API_URL).rstrip("/")
32
+
33
+
34
+ def get_api_key() -> Optional[str]:
35
+ """Get active API key from env or credentials file."""
36
+ # 1. Environment variable override
37
+ if os.getenv("DOUSHI_API_KEY"):
38
+ return os.environ["DOUSHI_API_KEY"].strip()
39
+
40
+ # 2. Stored credentials
41
+ creds = load_credentials()
42
+ current_context = creds.get("current_context", "default")
43
+ contexts = creds.get("contexts", {})
44
+ context_data = contexts.get(current_context, {})
45
+ return context_data.get("api_key")
46
+
47
+
48
+ def load_credentials() -> Dict[str, Any]:
49
+ """Load credentials JSON from ~/.doushi/credentials."""
50
+ if not CREDENTIALS_FILE.exists():
51
+ return {"current_context": "default", "contexts": {}}
52
+ try:
53
+ with open(CREDENTIALS_FILE, "r", encoding="utf-8") as f:
54
+ return json.load(f)
55
+ except Exception:
56
+ return {"current_context": "default", "contexts": {}}
57
+
58
+
59
+ def save_credentials(api_key: str, user_email: Optional[str] = None, org_name: Optional[str] = None, tier: Optional[str] = None, context: str = "default") -> None:
60
+ """Save credentials to ~/.doushi/credentials with secure 0600 permissions."""
61
+ ensure_doushi_dir()
62
+ creds = load_credentials()
63
+ if "contexts" not in creds:
64
+ creds["contexts"] = {}
65
+
66
+ creds["current_context"] = context
67
+ creds["contexts"][context] = {
68
+ "api_key": api_key,
69
+ "user_email": user_email,
70
+ "org_name": org_name,
71
+ "tier": tier,
72
+ }
73
+
74
+ with open(CREDENTIALS_FILE, "w", encoding="utf-8") as f:
75
+ json.dump(creds, f, indent=2)
76
+
77
+ try:
78
+ os.chmod(CREDENTIALS_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
79
+ except Exception:
80
+ pass
81
+
82
+
83
+ def delete_credentials() -> None:
84
+ """Remove stored credentials on logout."""
85
+ if CREDENTIALS_FILE.exists():
86
+ try:
87
+ CREDENTIALS_FILE.unlink()
88
+ except Exception:
89
+ pass
90
+
91
+
92
+ def load_config() -> Dict[str, Any]:
93
+ """Load settings from ~/.doushi/config.json."""
94
+ if not CONFIG_FILE.exists():
95
+ return {
96
+ "api_url": DEFAULT_API_URL,
97
+ "output_format": "table",
98
+ "auto_open_browser": True
99
+ }
100
+ try:
101
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
102
+ return json.load(f)
103
+ except Exception:
104
+ return {"api_url": DEFAULT_API_URL, "output_format": "table"}
105
+
106
+
107
+ def save_config(config: Dict[str, Any]) -> None:
108
+ """Save settings to ~/.doushi/config.json."""
109
+ ensure_doushi_dir()
110
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
111
+ json.dump(config, f, indent=2)