aisc-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.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,74 @@
1
+ """``aisc usage`` command layer (IDEA-2 2c).
2
+
3
+ Thin wrapper over ``aisc.application.usage`` — subscription status plus
4
+ per-provider token usage aggregated across all data-root workspaces.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, Optional
10
+
11
+
12
+ def cmd_usage_overview(args: Any) -> Dict[str, Any]:
13
+ from aisc.application.usage import usage_overview
14
+
15
+ return usage_overview(
16
+ range_key=getattr(args, "range", "7d") or "7d",
17
+ workspace=getattr(args, "workspace", None),
18
+ )
19
+
20
+
21
+ def _fmt_tokens(n: int) -> str:
22
+ if n >= 1_000_000_000:
23
+ return f"{n / 1_000_000_000:.2f}B"
24
+ if n >= 1_000_000:
25
+ return f"{n / 1_000_000:.2f}M"
26
+ if n >= 1_000:
27
+ return f"{n / 1_000:.1f}k"
28
+ return str(n)
29
+
30
+
31
+ def print_usage_overview_text(data: Optional[Dict[str, Any]]) -> None:
32
+ """Human-readable output (secret-free: masked subscription URL only)."""
33
+ if not isinstance(data, dict):
34
+ return
35
+ sub = data.get("subscription") or {}
36
+ if sub.get("configured"):
37
+ userinfo = sub.get("userinfo")
38
+ if isinstance(userinfo, dict):
39
+ total = userinfo.get("total")
40
+ used = userinfo.get("upload", 0) + userinfo.get("download", 0)
41
+ usage_bit = (f"{_fmt_tokens(used)} / {_fmt_tokens(total)} bytes"
42
+ if total else f"{_fmt_tokens(used)} bytes (unlimited)")
43
+ else:
44
+ # null userinfo = the source provided no usage header (manual
45
+ # content import, or a provider that omits subscription-userinfo)
46
+ usage_bit = "no usage info"
47
+ print(f"Subscription: {sub.get('url_masked') or '(manually imported)'}"
48
+ f" [{sub.get('source')}] {usage_bit}")
49
+ else:
50
+ print("Subscription: none configured")
51
+
52
+ print(f"Range: {data.get('range')}")
53
+ for ws in data.get("workspaces", []):
54
+ name = ws.get("workspace_path") or ws.get("workspace_hash") or "?"
55
+ try:
56
+ from pathlib import Path as _P
57
+ name = _P(str(name)).name or str(name)
58
+ except Exception:
59
+ pass
60
+ state = ("live" if ws.get("source") == "live"
61
+ else "cache" if ws.get("source") == "cache" else "no data")
62
+ print(f"\nWorkspace {name} [{state}]")
63
+ for p in ws.get("providers", []):
64
+ print(f" {p['app']:7} {p['provider_name']:24} "
65
+ f"req={p['requests']:>6} ok={p['success']:>6} "
66
+ f"tokens={_fmt_tokens(p['tokens_total']):>9} "
67
+ f"${p['cost_estimate']:.4f}")
68
+ if not ws.get("providers"):
69
+ print(" (no usage rows)")
70
+
71
+ totals = data.get("totals") or {}
72
+ print(f"\nTotals: requests={totals.get('requests', 0)} "
73
+ f"tokens={_fmt_tokens(totals.get('tokens_total', 0))} "
74
+ f"cost=${totals.get('cost_estimate', 0.0):.4f}")
@@ -0,0 +1,130 @@
1
+ """Build configuration wizard for aisc build command.
2
+
3
+ Interactive wizard that prompts for:
4
+ - Image name/tag
5
+ - Proxy configuration
6
+ - Build cache options
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional, Tuple
14
+
15
+
16
+ def _prompt(message: str, default: str = "") -> str:
17
+ """Prompt user for input with optional default."""
18
+ if default:
19
+ prompt_text = f"{message} [{default}]: "
20
+ else:
21
+ prompt_text = f"{message}: "
22
+
23
+ try:
24
+ response = input(prompt_text).strip()
25
+ return response if response else default
26
+ except (EOFError, KeyboardInterrupt):
27
+ print("\nCancelled.", file=sys.stderr)
28
+ sys.exit(130)
29
+
30
+
31
+ def _prompt_yes_no(message: str, default: bool = False) -> bool:
32
+ """Prompt user for yes/no confirmation."""
33
+ default_str = "Y/n" if default else "y/N"
34
+ prompt_text = f"{message} [{default_str}]: "
35
+
36
+ try:
37
+ response = input(prompt_text).strip().lower()
38
+ if not response:
39
+ return default
40
+ return response in ("y", "yes")
41
+ except (EOFError, KeyboardInterrupt):
42
+ print("\nCancelled.", file=sys.stderr)
43
+ sys.exit(130)
44
+
45
+
46
+ def run_build_wizard(
47
+ default_tag: str = "super-claude:latest",
48
+ aisc_root: Optional[Path] = None,
49
+ ) -> Tuple[str, bool, bool, Optional[str]]:
50
+ """Run interactive build configuration wizard.
51
+
52
+ Args:
53
+ default_tag: Default image tag to suggest
54
+ aisc_root: AISC repository root for proxy config path
55
+
56
+ Returns:
57
+ Tuple of (tag, no_cache, pull, proxy_config_path)
58
+ - tag: Docker image tag
59
+ - no_cache: Whether to disable build cache
60
+ - pull: Whether to pull base image
61
+ - proxy_config_path: Path to proxy config file, or None
62
+ """
63
+ print("\n🚀 AISC Build Configuration Wizard")
64
+ print("=" * 50)
65
+
66
+ # 1. Image name/tag
67
+ print("\n📦 Docker Image Configuration")
68
+ tag = _prompt("Image tag", default=default_tag)
69
+ if ":" not in tag:
70
+ tag = f"{tag}:latest"
71
+
72
+ # 2. Build cache
73
+ print("\n🔧 Build Options")
74
+ no_cache = _prompt_yes_no("Disable Docker build cache?", default=False)
75
+ pull = _prompt_yes_no("Always pull base image?", default=False)
76
+
77
+ # 3. Proxy configuration
78
+ print("\n🌐 Proxy Configuration (for container network access)")
79
+ use_proxy = _prompt_yes_no("Configure proxy network?", default=False)
80
+
81
+ proxy_config_path: Optional[str] = None
82
+ if use_proxy:
83
+ print("\nProxy configuration options:")
84
+ print(" 1) Local file - Enter absolute path to config file")
85
+ print(" 2) URL - Enter subscription/config URL")
86
+
87
+ mode = _prompt("Select [1/2]", default="2")
88
+
89
+ if mode == "1":
90
+ # Local file → store as manually imported subscription content
91
+ path_input = _prompt("Local config file absolute path")
92
+ if path_input and Path(path_input).is_file():
93
+ from aisc.application.network_subscription import (
94
+ import_subscription_content,
95
+ )
96
+ try:
97
+ snap = import_subscription_content(Path(path_input).read_bytes())
98
+ proxy_config_path = snap["config_path"]
99
+ print(f"✅ Config imported: {snap['config_path']}")
100
+ except Exception as e: # noqa: BLE001 - wizard stays chatty
101
+ print(f"❌ Import failed: {e}")
102
+ else:
103
+ print(f"⚠️ File not found: {path_input}, skipping proxy.")
104
+ else:
105
+ # URL download (IDEA-2: data-root subscription store; the clash
106
+ # UA, retries and userinfo capture live in the application layer)
107
+ url = _prompt("Config URL")
108
+ if url:
109
+ from aisc.application.network_subscription import import_subscription
110
+ from aisc.domain.models import CliError
111
+
112
+ print("⬇️ Downloading subscription...")
113
+ try:
114
+ snap = import_subscription(url)
115
+ proxy_config_path = snap["config_path"]
116
+ print(f"✅ Subscription imported: {snap['config_path']}")
117
+ if snap.get("userinfo"):
118
+ print(f"📊 Usage header captured: {snap['userinfo']}")
119
+ except CliError as e:
120
+ print(f"❌ Import failed: {e.message}")
121
+
122
+ print("\n" + "=" * 50)
123
+ print("Configuration Summary:")
124
+ print(f" Image tag: {tag}")
125
+ print(f" No cache: {no_cache}")
126
+ print(f" Pull base: {pull}")
127
+ print(f" Proxy config: {proxy_config_path or 'None (direct connection)'}")
128
+ print("=" * 50 + "\n")
129
+
130
+ return tag, no_cache, pull, proxy_config_path
@@ -0,0 +1,162 @@
1
+ """F2-C addendum (user request 2026-09-09): ``aisc workspaces`` — the
2
+ machine-wide management view of CLI-activated workspaces.
3
+
4
+ Scans every workspace registry under the data root, joins the activation
5
+ history for aliases, and cross-references live Docker state. ``--stop``
6
+ sweeps the RUNNING CLI-owned activations (Workbench-managed runtimes are
7
+ never touched — the GUI owns their lifecycle).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ from aisc.adapters.container_registry import (
16
+ gc as registry_gc,
17
+ list_containers,
18
+ unregister,
19
+ )
20
+ from aisc.adapters.docker_ import DockerExecutor, RealDockerExecutor
21
+ from aisc.application.data_root import DataRootResolver
22
+
23
+
24
+ def registry_entries(
25
+ executor: Optional[DockerExecutor] = None,
26
+ data_root: Optional[str] = None,
27
+ ) -> Tuple[List[Any], set]:
28
+ """Every ``(name, meta)`` across all workspace registries (CLI and
29
+ Workbench-owned alike — the caller filters), plus the set of registry
30
+ DEFAULT pointers (the bare ``aisc claude`` target of each workspace —
31
+ r2 #B: stars must mark the pointer, not "same workspace").
32
+
33
+ Lazy-GCs each registry so stale records from deleted containers do not
34
+ surface. *data_root* (a dir containing ``workspaces/``) overrides the
35
+ resolved shared root — injection seam for tests and ``aisc ps``.
36
+ """
37
+ from aisc.adapters.container_registry import get_default
38
+
39
+ base = Path(data_root) if data_root else DataRootResolver().resolve_shared_root()
40
+ shared = base / "workspaces"
41
+ out: List[Any] = []
42
+ defaults: set = set()
43
+ if not shared.is_dir():
44
+ return out, defaults
45
+ for reg_dir in sorted(shared.glob("*/runtime")):
46
+ if (reg_dir / "containers.json").is_file():
47
+ try:
48
+ registry_gc(reg_dir, executor)
49
+ except Exception:
50
+ pass
51
+ out.extend(list_containers(reg_dir).items())
52
+ try:
53
+ d = get_default(reg_dir)
54
+ except Exception:
55
+ d = ""
56
+ if d:
57
+ defaults.add(d)
58
+ return out, defaults
59
+
60
+
61
+ def docker_states(executor: DockerExecutor) -> Tuple[Dict[str, str], bool]:
62
+ """name → docker status string (one ``docker ps -a`` round trip).
63
+
64
+ Second element: whether docker answered at all. When False the map is
65
+ empty and callers must NOT read absence as "container gone".
66
+ """
67
+ proc = executor.run_captured([
68
+ "ps", "-a", "--format", "{{.Names}}\t{{.Status}}",
69
+ ])
70
+ states: Dict[str, str] = {}
71
+ if proc.exit_code != 0:
72
+ return states, False
73
+ for line in (proc.stdout or "").splitlines():
74
+ name, _, status = line.partition("\t")
75
+ if name:
76
+ states[name] = status
77
+ return states, True
78
+
79
+
80
+ def cmd_workspaces(
81
+ executor: Optional[DockerExecutor] = None,
82
+ ) -> List[Dict[str, Any]]:
83
+ """List every CLI-activated workspace with live Docker state."""
84
+ exec_ = executor or RealDockerExecutor()
85
+ from aisc.cli.commands.runs import list_runs
86
+
87
+ alias_by_path = {r.get("path", ""): r.get("alias", "") for r in list_runs()}
88
+ states, _docker_ok = docker_states(exec_)
89
+ entries, defaults = registry_entries(exec_)
90
+
91
+ rows: List[Dict[str, Any]] = []
92
+ for cname, meta in entries:
93
+ meta = meta or {}
94
+ if meta.get("owner") == "workbench":
95
+ continue
96
+ name = str(cname)
97
+ if not name:
98
+ continue
99
+ ws = str(meta.get("workspace", ""))
100
+ status = states.get(name, "")
101
+ rows.append({
102
+ "workspace": ws,
103
+ "alias": alias_by_path.get(ws, ""),
104
+ "label": str(meta.get("label", "")),
105
+ "container": name,
106
+ "image": str(meta.get("image", "")),
107
+ "status": status,
108
+ "running": status.startswith("Up"),
109
+ # r2 #E: which row a bare `aisc claude` actually lands in —
110
+ # the registry default pointer, not "any row of that workspace"
111
+ "active": name in defaults,
112
+ })
113
+ rows.sort(key=lambda r: (not r["running"], r["workspace"]))
114
+ return rows
115
+
116
+
117
+ def cmd_workspaces_stop(
118
+ executor: Optional[DockerExecutor] = None,
119
+ ) -> Dict[str, Any]:
120
+ """Stop + remove every RUNNING CLI-owned activation (the batch sweep)."""
121
+ exec_ = executor or RealDockerExecutor()
122
+ from aisc.application.data_root import DataRootResolver
123
+
124
+ rows = [r for r in cmd_workspaces(executor=exec_) if r["running"]]
125
+ stopped: List[Dict[str, Any]] = []
126
+ shared = DataRootResolver().resolve_shared_root() / "workspaces"
127
+ for row in rows:
128
+ name = row["container"]
129
+ stop = exec_.run_captured(["stop", name], timeout=30.0)
130
+ rm = exec_.run_captured(["rm", "-f", name], timeout=30.0)
131
+ # unregister from its workspace registry (best-effort locate)
132
+ try:
133
+ for reg_dir in shared.glob("*/runtime"):
134
+ if (reg_dir / "containers.json").is_file():
135
+ unregister(reg_dir, name)
136
+ except Exception:
137
+ pass
138
+ stopped.append({
139
+ "workspace": row["workspace"],
140
+ "alias": row["alias"],
141
+ "container": name,
142
+ "exit_code": rm.exit_code if rm.exit_code != 0 else stop.exit_code,
143
+ })
144
+ return {"stopped": stopped, "running_found": len(rows)}
145
+
146
+
147
+ def print_workspaces_text(rows: List[Dict[str, Any]]) -> None:
148
+ """Human table: alias, status, workspace (ACTIVE workspace starred)."""
149
+ if not rows:
150
+ print("当前没有 CLI 激活的工作区 — aisc run <路径> 开始")
151
+ return
152
+ width = max(len(r.get("alias", "") or "-")
153
+ + (len(r.get("label", "")) + 4 if r.get("label") else 0)
154
+ for r in rows) + 2
155
+ for r in rows:
156
+ alias = r.get("alias", "") or "-"
157
+ if r.get("label"):
158
+ alias = f"{alias}[{r['label']}]" # bypass slot — distinguish
159
+ alias += " *" if r.get("active") else ""
160
+ status = r.get("status", "") or "(容器不存在)"
161
+ print(f" {alias:<{width}} {status:<22} {r['workspace']}")
162
+ print("批量停止运行中的: aisc workspaces --stop · 单个: aisc stop")