feat: flatten opportunity commands, add config management, fix completions
- Flatten opportunity into top-level portfolio and opportunity commands - Add interactive config get/set/key/secret with type coercion - Rewrite --doc to show TUI vs JSON schema per command - Unify agent mode output to JSON only - Make init prompt for API key/secret interactively - Fix coin tab completion alias binding - Fix set_config_value reading from wrong path - Fail loudly on invalid numeric config values Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,11 @@ try:
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
import tomli as tomllib
|
||||
|
||||
try:
|
||||
import tomli_w
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
tomli_w = None # type: ignore[assignment]
|
||||
|
||||
|
||||
DEFAULT_CONFIG = """[runtime]
|
||||
timezone = "Asia/Shanghai"
|
||||
@@ -128,3 +133,72 @@ def resolve_log_dir(config: dict[str, Any], paths: RuntimePaths | None = None) -
|
||||
raw = config.get("runtime", {}).get("log_dir", "logs")
|
||||
value = Path(raw).expanduser()
|
||||
return value if value.is_absolute() else paths.root / value
|
||||
|
||||
|
||||
def get_config_value(config: dict[str, Any], key_path: str) -> Any:
|
||||
keys = key_path.split(".")
|
||||
node = config
|
||||
for key in keys:
|
||||
if not isinstance(node, dict) or key not in node:
|
||||
return None
|
||||
node = node[key]
|
||||
return node
|
||||
|
||||
|
||||
def set_config_value(config_file: Path, key_path: str, value: Any) -> None:
|
||||
if tomli_w is None:
|
||||
raise RuntimeError("tomli-w is not installed. Run `pip install tomli-w`.")
|
||||
if not config_file.exists():
|
||||
raise RuntimeError(f"Config file not found: {config_file}")
|
||||
config = tomllib.loads(config_file.read_text(encoding="utf-8"))
|
||||
keys = key_path.split(".")
|
||||
node = config
|
||||
for key in keys[:-1]:
|
||||
if key not in node:
|
||||
node[key] = {}
|
||||
node = node[key]
|
||||
|
||||
# Coerce type from existing value when possible
|
||||
existing = node.get(keys[-1])
|
||||
if isinstance(existing, bool) and isinstance(value, str):
|
||||
value = value.lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(existing, (int, float)) and isinstance(value, str):
|
||||
try:
|
||||
value = type(existing)(value)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise RuntimeError(
|
||||
f"Cannot set {key_path} to {value!r}: expected {type(existing).__name__}, got {value!r}"
|
||||
) from exc
|
||||
elif isinstance(existing, list) and isinstance(value, str):
|
||||
value = [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
node[keys[-1]] = value
|
||||
config_file.write_text(tomli_w.dumps(config), encoding="utf-8")
|
||||
|
||||
|
||||
def get_env_value(paths: RuntimePaths | None = None, key: str = "") -> str:
|
||||
paths = paths or get_runtime_paths()
|
||||
if not paths.env_file.exists():
|
||||
return ""
|
||||
env_data = load_env_file(paths)
|
||||
return env_data.get(key, "")
|
||||
|
||||
|
||||
def set_env_value(paths: RuntimePaths | None = None, key: str = "", value: str = "") -> None:
|
||||
paths = paths or get_runtime_paths()
|
||||
if not paths.env_file.exists():
|
||||
raise RuntimeError(f"Env file not found: {paths.env_file}. Run `coin init` first.")
|
||||
|
||||
lines = paths.env_file.read_text(encoding="utf-8").splitlines()
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(f"{key}=") or stripped.startswith(f"{key} ="):
|
||||
lines[i] = f"{key}={value}"
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
lines.append(f"{key}={value}")
|
||||
|
||||
paths.env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
os.environ[key] = value
|
||||
|
||||
Reference in New Issue
Block a user