Skip to content

Commit 46d370e

Browse files
feat: instance profiles for multi-instance config (#64)
* feat: add instance profiles for multi-instance config Adds named instance configurations stored in ~/.ascend-tools/config.toml, similar to Snowflake CLI connections or AWS profiles. Users can configure multiple Ascend instances and switch between them with --instance <name> or ASCEND_INSTANCE env var. Config resolution order: CLI flags > instance config > env vars > error. Fully backward compatible — existing env var workflows continue to work unchanged when no config file is present. Key changes: - New InstanceEntry type and TOML config file parsing in ascend-tools-core - Config::with_overrides_and_instance() method with 4-level resolution - instance_config module for add/remove/list/set-default operations - New `instance` CLI subcommand (add, list, remove, set-default) - Global --instance flag and ASCEND_INSTANCE env var support - Python Client(instance="staging") and JS Client(null, null, null, "staging") - service_account_key_env field stores env var name (never the secret) - Uses toml_edit for format-preserving config file writes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use print_subcommand_help for instance command consistency Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rename --service-account-key-env to --key-env on instance add Avoids confusion with the global --service-account-key flag. Clap was suggesting --service-account-key (the actual secret) when users mistyped --service-account-key-env (the env var name). Shorter flag is clearer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: revert to --service-account-key-env flag name Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove Clap env= on auth flags so instance config takes priority Clap's env attribute reads env vars and treats them as CLI-level overrides, which meant shell env vars always won over instance config. The config module already handles env vars as a proper fallback, so Clap's env handling was redundant and broke the resolution order. Also fix integration tests to pass auth via CLI flags (highest priority) so they're immune to config file state on the test machine. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: harden instance config management - Fix double config file read in load_instance_entry error path by using remove_entry() instead of into_iter().find() + redundant load - Add instance name validation (alphanumeric, hyphens, underscores only; reject reserved "default_instance" key) - Auto-set default_instance on first instance add when no default exists - Clear dangling default_instance reference when removing the default instance - Show effective key_env in instance add confirmation message Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: comprehensive coverage for instance config Refactor config internals to separate pure logic from I/O: - Extract select_instance_entry() for testable instance routing - Add path-parameterized CRUD helpers (add_at, remove_at, etc.) Add 25 new tests covering all previously untested branches: - select_instance_entry: all 8 routing paths (explicit found/not-found, default found/missing, implicit fallback, available list) - parse_config_toml edge cases: only-default-key, non-table values ignored, wrong-type fields error, extra fields forward compat - CRUD filesystem tests: add creates file + auto-sets default, second add preserves default, add rejects invalid names, update existing, remove instance, remove clears dangling default, remove nonexistent errors, list empty/nonexistent, set-default, set-default nonexistent Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add instance profiles documentation across all SDKs - CLI: add instance config section, resolution order, manage instances - Python: document Client(instance="staging") and resolution order - JavaScript: document named instance constructor pattern - Rust: document Config::with_overrides_and_instance() - MCP: document config.toml and ASCEND_INSTANCE inheritance - Fix rustfmt formatting in config.rs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: deduplicate instance config CRUD and eliminate magic strings - Extract load_document(), load_or_create_document(), save_document() helpers to remove 3x read+parse and 3x write boilerplate - Extract available_instances() helper for 2x duplicate listing pattern - Add DEFAULT_INSTANCE_KEY and DEFAULT_INSTANCE_NAME constants replacing 12 "default_instance" and 2 "default" magic string literals - Use print_json() in instance list for consistency with other CLI handlers - Remove unused config_dir_path() - Remove unnecessary WHAT comments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bc92c2a commit 46d370e

File tree

20 files changed

+1493
-65
lines changed

20 files changed

+1493
-65
lines changed

AGENTS.md

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,41 @@ After code changes, always run `bin/check` before committing.
2828

2929
## authentication
3030

31-
Three env vars are required:
31+
### instance config (recommended)
32+
33+
Configure named instances in `~/.ascend-tools/config.toml`:
34+
35+
```bash
36+
ascend-tools instance add default \
37+
--service-account-id "asc-sa-..." \
38+
--instance-api-url "https://api.instance.ascend.io" \
39+
--service-account-key-env ASCEND_SERVICE_ACCOUNT_KEY
40+
export ASCEND_SERVICE_ACCOUNT_KEY="..."
41+
```
42+
43+
Config file format (`~/.ascend-tools/config.toml`):
44+
45+
```toml
46+
default_instance = "production" # optional, defaults to "default"
47+
48+
[default]
49+
service_account_id = "asc-sa-abc123"
50+
instance_api_url = "https://api.myinstance.ascend.io"
51+
service_account_key_env = "ASCEND_SERVICE_ACCOUNT_KEY"
52+
53+
[staging]
54+
service_account_id = "asc-sa-def456"
55+
instance_api_url = "https://api.staging.ascend.io"
56+
service_account_key_env = "ASCEND_STAGING_KEY"
57+
```
58+
59+
`service_account_key_env` stores the env var **name** (not the secret). The tool reads that env var at runtime.
60+
61+
Switch instances: `--instance <name>` flag or `ASCEND_INSTANCE` env var.
62+
63+
### environment variables
64+
65+
Three env vars work as a fallback (backward compatible):
3266

3367
| Variable | Description |
3468
|----------|-------------|
@@ -38,6 +72,13 @@ Three env vars are required:
3872

3973
All three SDKs read these automatically — `Config::from_env()` (Rust), `ascend_tools.Client()` (Python), `new Client()` (JavaScript).
4074

75+
### resolution order
76+
77+
1. CLI flags (`--service-account-id`, etc.) — highest priority
78+
2. Instance config from TOML (selected by `--instance` or `ASCEND_INSTANCE` env var)
79+
3. Env vars (`ASCEND_SERVICE_ACCOUNT_ID`, etc.) — fallback
80+
4. Error
81+
4182
Auth params can also be passed as CLI flags (`--service-account-id`, `--service-account-key`, etc.). Secret values are hidden in `--help` output.
4283

4384
### local dev
@@ -49,7 +90,12 @@ export ASCEND_INSTANCE_API_URL="https://<workspace>-instance.api.local.ascend.de
4990
## CLI reference
5091

5192
```
52-
ascend-tools [-o text|json] [-V]
93+
ascend-tools [-o text|json] [-V] [--instance <NAME>]
94+
95+
instance add <NAME> --service-account-id <ID> --instance-api-url <URL> [--service-account-key-env <ENV_VAR>]
96+
instance list
97+
instance remove <NAME>
98+
instance set-default <NAME>
5399
54100
workspace list [--environment <NAME>] [--project <NAME>]
55101
workspace get <TITLE>
@@ -138,9 +184,12 @@ No subcommand prints help.
138184
```python
139185
from ascend_tools import Client
140186

141-
# All params optional — resolved from env vars if not provided
187+
# All params optional — resolved from instance config or env vars
142188
client = Client()
143189

190+
# Use a specific named instance from ~/.ascend-tools/config.toml
191+
client = Client(instance="staging")
192+
144193
# Or explicit — only need the instance API URL
145194
client = Client(
146195
service_account_id="asc-sa-...",
@@ -194,9 +243,12 @@ All methods return `dict` or `list[dict]`. All parameters are keyword-only.
194243
```javascript
195244
import { Client } from "ascend-tools";
196245

197-
// All params optional — resolved from env vars if not provided
246+
// All params optional — resolved from instance config or env vars
198247
const client = new Client();
199248

249+
// Use a specific named instance from ~/.ascend-tools/config.toml
250+
const client = new Client(null, null, null, "staging");
251+
200252
// Or explicit
201253
const client = new Client(
202254
"asc-sa-...", // serviceAccountId

Cargo.lock

Lines changed: 109 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ascend-tools-cli/src/cli.rs

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::common::OutputMode;
88
use crate::deployment::DeploymentCommands;
99
use crate::environment::EnvironmentCommands;
1010
use crate::flow::FlowCommands;
11+
use crate::instance::InstanceCommands;
1112
use crate::otto::OttoCommands;
1213
use crate::profile::ProfileCommands;
1314
use crate::project::ProjectCommands;
@@ -24,28 +25,22 @@ pub(crate) struct CliParser {
2425
#[arg(short, long, global = true, value_enum, default_value_t = OutputMode::Text)]
2526
output: OutputMode,
2627

27-
/// Service account ID
28-
#[arg(
29-
long,
30-
global = true,
31-
env = "ASCEND_SERVICE_ACCOUNT_ID",
32-
hide_env_values = true
33-
)]
28+
/// Service account ID (overrides instance config and env var)
29+
#[arg(long, global = true)]
3430
service_account_id: Option<String>,
3531

36-
/// Service account key
37-
#[arg(
38-
long,
39-
global = true,
40-
env = "ASCEND_SERVICE_ACCOUNT_KEY",
41-
hide_env_values = true
42-
)]
32+
/// Service account key (overrides instance config and env var)
33+
#[arg(long, global = true)]
4334
service_account_key: Option<String>,
4435

45-
/// Instance API URL
46-
#[arg(long, global = true, env = "ASCEND_INSTANCE_API_URL")]
36+
/// Instance API URL (overrides instance config and env var)
37+
#[arg(long, global = true)]
4738
instance_api_url: Option<String>,
4839

40+
/// Instance name from ~/.ascend-tools/config.toml
41+
#[arg(long, global = true, env = "ASCEND_INSTANCE")]
42+
instance: Option<String>,
43+
4944
#[command(subcommand)]
5045
command: Option<Commands>,
5146
}
@@ -132,6 +127,19 @@ enum Commands {
132127
#[command(subcommand)]
133128
command: Option<OttoCommands>,
134129
},
130+
/// Manage instance configurations
131+
#[command(
132+
long_about = "Manage instance configurations stored in ~/.ascend-tools/config.toml.\n\n\
133+
Examples:\n \
134+
ascend-tools instance add production --service-account-id asc-sa-abc --instance-api-url https://api.prod.ascend.io --service-account-key-env ASCEND_PROD_KEY\n \
135+
ascend-tools instance list\n \
136+
ascend-tools instance remove staging\n \
137+
ascend-tools instance set-default production"
138+
)]
139+
Instance {
140+
#[command(subcommand)]
141+
command: Option<InstanceCommands>,
142+
},
135143
/// Start an MCP server
136144
Mcp {
137145
/// Use HTTP transport instead of stdio
@@ -173,11 +181,16 @@ where
173181
return crate::skill::handle_skill(command);
174182
}
175183

184+
if let Commands::Instance { command } = command {
185+
return crate::instance::handle_instance(command, &cli.output);
186+
}
187+
176188
if let Commands::Mcp { http, bind } = command {
177-
let config = Config::with_overrides(
189+
let config = Config::with_overrides_and_instance(
178190
cli.service_account_id.as_deref(),
179191
cli.service_account_key.as_deref(),
180192
cli.instance_api_url.as_deref(),
193+
cli.instance.as_deref(),
181194
);
182195
let rt = tokio::runtime::Runtime::new()?;
183196
return if http {
@@ -187,10 +200,11 @@ where
187200
};
188201
}
189202

190-
let config = Config::with_overrides(
203+
let config = Config::with_overrides_and_instance(
191204
cli.service_account_id.as_deref(),
192205
cli.service_account_key.as_deref(),
193206
cli.instance_api_url.as_deref(),
207+
cli.instance.as_deref(),
194208
)?;
195209

196210
let client = AscendClient::new(config)?;
@@ -213,7 +227,10 @@ where
213227
}
214228
Commands::Flow { command } => crate::flow::handle_flow(&client, command, &cli.output),
215229
Commands::Otto { command } => crate::otto::handle_otto_cmd(&client, command, &cli.output),
216-
Commands::Mcp { .. } | Commands::Skill { .. } | Commands::Signup => unreachable!(),
230+
Commands::Instance { .. }
231+
| Commands::Mcp { .. }
232+
| Commands::Skill { .. }
233+
| Commands::Signup => unreachable!(),
217234
}
218235
}
219236

0 commit comments

Comments
 (0)