mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 10:15:40 +00:00
Merge pull request #1590 from kaitranntt/dev
feat: release CCS Bar one-click updater + accumulated dev fixes
This commit is contained in:
62 files changed
+1407
-329
No files matched your search
@@ -60,6 +60,10 @@ jobs:
|
||||
# gh lives in Homebrew on the self-hosted runner; ensure it is on PATH.
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
gh release upload ccs-bar-latest macos-bar/dist/CCS-Bar.app.zip --clobber
|
||||
printf '%s\n' "${VERSION}" > version.txt
|
||||
gh release upload ccs-bar-latest version.txt --clobber
|
||||
echo "[OK] Published version.txt (${VERSION}) to ccs-bar-latest"
|
||||
rm -f version.txt
|
||||
notes="$(mktemp)"
|
||||
cat > "$notes" <<NOTES
|
||||
CCS Bar v${VERSION}
|
||||
|
||||
@@ -6,6 +6,7 @@ your API profile points at an OpenAI-compatible chat completions endpoint.
|
||||
This is useful for providers such as:
|
||||
|
||||
- Hugging Face Inference Providers
|
||||
- Tuning Engines
|
||||
- OpenRouter
|
||||
- Ollama
|
||||
- llama.cpp servers
|
||||
@@ -48,10 +49,18 @@ Create or reuse an API profile that points at an OpenAI-compatible endpoint:
|
||||
ccs api create --preset hf
|
||||
```
|
||||
|
||||
For Tuning Engines:
|
||||
|
||||
```bash
|
||||
ccs api create --preset te
|
||||
```
|
||||
|
||||
Then you can use the profile directly:
|
||||
|
||||
```bash
|
||||
ccs hf
|
||||
# or
|
||||
ccs te
|
||||
```
|
||||
|
||||
CCS detects that the profile is OpenAI-compatible and auto-routes Claude Code
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
"src/api/services/profile-lifecycle-service.ts:127",
|
||||
"src/api/services/profile-lifecycle-service.ts:73",
|
||||
"src/api/services/profile-lifecycle-service.ts:88",
|
||||
"src/api/services/profile-writer.ts:364",
|
||||
"src/api/services/profile-writer.ts:415",
|
||||
"src/bin/ccsxp-runtime.ts:90",
|
||||
"src/channels/official-channels-store.ts:291",
|
||||
"src/channels/official-channels-store.ts:296",
|
||||
@@ -39,7 +37,7 @@
|
||||
"src/cliproxy/binary/installer.ts:55",
|
||||
"src/cliproxy/binary/installer.ts:74",
|
||||
"src/cliproxy/binary/installer.ts:88",
|
||||
"src/cliproxy/binary/lifecycle.ts:139",
|
||||
"src/cliproxy/binary/lifecycle.ts:137",
|
||||
"src/cliproxy/binary/platform-detector.ts:153",
|
||||
"src/cliproxy/binary/platform-detector.ts:160",
|
||||
"src/cliproxy/binary/verifier.ts:55",
|
||||
@@ -48,8 +46,6 @@
|
||||
"src/cliproxy/config/base-config-loader.ts:66",
|
||||
"src/cliproxy/config/base-config-loader.ts:81",
|
||||
"src/cliproxy/config/base-config-loader.ts:91",
|
||||
"src/cliproxy/config/env-builder.ts:1005",
|
||||
"src/cliproxy/config/env-builder.ts:700",
|
||||
"src/cliproxy/executor/auth-coordinator.ts:176",
|
||||
"src/cliproxy/executor/auth-coordinator.ts:317",
|
||||
"src/cliproxy/executor/browser-launch-setup.ts:120",
|
||||
@@ -62,7 +58,6 @@
|
||||
"src/cliproxy/executor/proxy-resolver.ts:163",
|
||||
"src/cliproxy/executor/proxy-resolver.ts:177",
|
||||
"src/cliproxy/executor/session-bridge.ts:147",
|
||||
"src/cliproxy/provider-capabilities.ts:257",
|
||||
"src/cliproxy/proxy/https-tunnel-proxy.ts:61",
|
||||
"src/cliproxy/routing/routing-strategy.ts:480",
|
||||
"src/cliproxy/routing/routing-strategy.ts:488",
|
||||
@@ -121,10 +116,10 @@
|
||||
"src/commands/config-channels-command.ts:436",
|
||||
"src/commands/config-channels-command.ts:447",
|
||||
"src/commands/persist-command/arg-parsing.ts:31",
|
||||
"src/commands/persist-command/backup-rotation.ts:226",
|
||||
"src/commands/persist-command/backup-rotation.ts:244",
|
||||
"src/commands/persist-command/backup-rotation.ts:249",
|
||||
"src/commands/persist-command/backup-rotation.ts:86",
|
||||
"src/commands/persist-command/backup-rotation.ts:235",
|
||||
"src/commands/persist-command/backup-rotation.ts:253",
|
||||
"src/commands/persist-command/backup-rotation.ts:258",
|
||||
"src/commands/persist-command/backup-rotation.ts:87",
|
||||
"src/commands/persist-command/handler.ts:166",
|
||||
"src/commands/persist-command/handler.ts:38",
|
||||
"src/commands/persist-command/secure-file.ts:104",
|
||||
@@ -134,14 +129,13 @@
|
||||
"src/commands/persist-command/secure-file.ts:174",
|
||||
"src/commands/persist-command/secure-file.ts:182",
|
||||
"src/commands/persist-command/secure-file.ts:98",
|
||||
"src/commands/proxy-command.ts:89",
|
||||
"src/commands/setup-command.ts:218",
|
||||
"src/commands/setup-command.ts:227",
|
||||
"src/config/loader/io-locks.ts:205",
|
||||
"src/config/loader/io-locks.ts:241",
|
||||
"src/config/loader/io-locks.ts:295",
|
||||
"src/config/loader/io-locks.ts:297",
|
||||
"src/config/reserved-names.ts:92",
|
||||
"src/config/unified-config-loader.ts:149",
|
||||
"src/config/unified-config-loader.ts:155",
|
||||
"src/copilot/copilot-package-manager.ts:444",
|
||||
"src/copilot/copilot-package-manager.ts:467",
|
||||
"src/cursor/cursor-anthropic-translator.ts:113",
|
||||
@@ -192,57 +186,53 @@
|
||||
"src/proxy/proxy-daemon-entry.ts:78",
|
||||
"src/proxy/proxy-daemon-entry.ts:88",
|
||||
"src/proxy/proxy-daemon.ts:98",
|
||||
"src/proxy/transformers/request-transformer.ts:141",
|
||||
"src/proxy/transformers/request-transformer.ts:182",
|
||||
"src/proxy/transformers/request-transformer.ts:189",
|
||||
"src/proxy/transformers/request-transformer.ts:242",
|
||||
"src/proxy/transformers/request-transformer.ts:259",
|
||||
"src/proxy/transformers/request-transformer.ts:278",
|
||||
"src/proxy/transformers/request-transformer.ts:344",
|
||||
"src/proxy/transformers/request-transformer.ts:360",
|
||||
"src/proxy/transformers/request-transformer.ts:370",
|
||||
"src/proxy/transformers/request-transformer.ts:390",
|
||||
"src/proxy/transformers/request-transformer.ts:428",
|
||||
"src/proxy/transformers/request-transformer.ts:439",
|
||||
"src/proxy/transformers/request-transformer.ts:443",
|
||||
"src/proxy/transformers/request-transformer.ts:459",
|
||||
"src/proxy/transformers/request-transformer.ts:468",
|
||||
"src/proxy/transformers/request-transformer.ts:487",
|
||||
"src/proxy/transformers/request-transformer.ts:493",
|
||||
"src/proxy/transformers/request-transformer.ts:528",
|
||||
"src/proxy/transformers/request-transformer.ts:533",
|
||||
"src/proxy/transformers/request-transformer.ts:538",
|
||||
"src/proxy/transformers/request-transformer.ts:543",
|
||||
"src/proxy/transformers/request-transformer.ts:561",
|
||||
"src/proxy/transformers/request-transformer.ts:566",
|
||||
"src/proxy/transformers/request-transformer.ts:573",
|
||||
"src/proxy/transformers/request-transformer.ts:582",
|
||||
"src/proxy/transformers/request-transformer.ts:633",
|
||||
"src/proxy/transformers/request-transformer.ts:639",
|
||||
"src/proxy/transformers/request-transformer.ts:644",
|
||||
"src/proxy/transformers/request-transformer.ts:665",
|
||||
"src/proxy/transformers/request-transformer.ts:144",
|
||||
"src/proxy/transformers/request-transformer.ts:185",
|
||||
"src/proxy/transformers/request-transformer.ts:192",
|
||||
"src/proxy/transformers/request-transformer.ts:245",
|
||||
"src/proxy/transformers/request-transformer.ts:262",
|
||||
"src/proxy/transformers/request-transformer.ts:281",
|
||||
"src/proxy/transformers/request-transformer.ts:347",
|
||||
"src/proxy/transformers/request-transformer.ts:363",
|
||||
"src/proxy/transformers/request-transformer.ts:373",
|
||||
"src/proxy/transformers/request-transformer.ts:393",
|
||||
"src/proxy/transformers/request-transformer.ts:431",
|
||||
"src/proxy/transformers/request-transformer.ts:442",
|
||||
"src/proxy/transformers/request-transformer.ts:446",
|
||||
"src/proxy/transformers/request-transformer.ts:462",
|
||||
"src/proxy/transformers/request-transformer.ts:471",
|
||||
"src/proxy/transformers/request-transformer.ts:490",
|
||||
"src/proxy/transformers/request-transformer.ts:496",
|
||||
"src/proxy/transformers/request-transformer.ts:531",
|
||||
"src/proxy/transformers/request-transformer.ts:536",
|
||||
"src/proxy/transformers/request-transformer.ts:541",
|
||||
"src/proxy/transformers/request-transformer.ts:546",
|
||||
"src/proxy/transformers/request-transformer.ts:564",
|
||||
"src/proxy/transformers/request-transformer.ts:569",
|
||||
"src/proxy/transformers/request-transformer.ts:576",
|
||||
"src/proxy/transformers/request-transformer.ts:585",
|
||||
"src/proxy/transformers/request-transformer.ts:636",
|
||||
"src/proxy/transformers/request-transformer.ts:642",
|
||||
"src/proxy/transformers/request-transformer.ts:647",
|
||||
"src/proxy/transformers/request-transformer.ts:668",
|
||||
"src/proxy/upstream-url.ts:28",
|
||||
"src/shared/claude-extension-setup.ts:240",
|
||||
"src/shared/claude-extension-setup.ts:247",
|
||||
"src/shared/claude-extension-setup.ts:257",
|
||||
"src/shared/claude-extension-setup.ts:317",
|
||||
"src/shared/provider-preset-catalog.ts:308",
|
||||
"src/shared/provider-preset-catalog.ts:311",
|
||||
"src/shared/provider-preset-catalog.ts:320",
|
||||
"src/shared/provider-preset-catalog.ts:323",
|
||||
"src/shared/provider-preset-catalog.ts:326",
|
||||
"src/shared/provider-preset-catalog.ts:331",
|
||||
"src/shared/provider-preset-catalog.ts:334",
|
||||
"src/shared/provider-preset-catalog.ts:325",
|
||||
"src/shared/provider-preset-catalog.ts:328",
|
||||
"src/shared/provider-preset-catalog.ts:337",
|
||||
"src/shared/provider-preset-catalog.ts:340",
|
||||
"src/shared/provider-preset-catalog.ts:343",
|
||||
"src/shared/provider-preset-catalog.ts:348",
|
||||
"src/shared/provider-preset-catalog.ts:351",
|
||||
"src/shared/toml-object.ts:23",
|
||||
"src/targets/codex-adapter.ts:103",
|
||||
"src/targets/codex-adapter.ts:275",
|
||||
"src/targets/codex-adapter.ts:319",
|
||||
"src/targets/codex-adapter.ts:326",
|
||||
"src/targets/codex-adapter.ts:363",
|
||||
"src/targets/codex-cliproxy-provider-config.ts:138",
|
||||
"src/targets/codex-cliproxy-provider-config.ts:141",
|
||||
"src/targets/codex-cliproxy-provider-config.ts:151",
|
||||
"src/targets/codex-cliproxy-provider-config.ts:177",
|
||||
"src/targets/droid-adapter.ts:32",
|
||||
"src/targets/droid-adapter.ts:35",
|
||||
"src/targets/droid-adapter.ts:68",
|
||||
|
||||
@@ -76,7 +76,13 @@ struct BarMenuView: View {
|
||||
// genuine pool/model overflow.
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// (1) ALERTS first — urgent quota crossings surface above everything.
|
||||
// (1) UPDATE BANNER — shown only when a newer version is available.
|
||||
// Placed at the very top so it is seen without scrolling.
|
||||
if viewModel.updateAvailable {
|
||||
updateBanner
|
||||
}
|
||||
|
||||
// (2) ALERTS — urgent quota crossings surface above accounts.
|
||||
// Spend-cap alerts are opt-in OFF by default, so by default only
|
||||
// quota/reauth/cooldown conditions appear here.
|
||||
if !viewModel.activeAlerts.isEmpty {
|
||||
@@ -88,10 +94,10 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// (2) SUBSCRIPTIONS — the dominant section, opens here.
|
||||
// (3) SUBSCRIPTIONS — the dominant section, opens here.
|
||||
accountsSection
|
||||
|
||||
// (3) SPEND — demoted to a thin informational strip below the cockpit.
|
||||
// (4) SPEND — demoted to a thin informational strip below the cockpit.
|
||||
// spendChartStyle and spendPeriod are threaded from the viewModel and
|
||||
// toggled/selected inline from the Spend header so changes are live.
|
||||
if let analytics = viewModel.analytics {
|
||||
@@ -107,10 +113,10 @@ struct BarMenuView: View {
|
||||
onSelectPeriod: { viewModel.spendPeriod = $0 })
|
||||
}
|
||||
|
||||
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
|
||||
// (5) POOL ACCOUNTS — compact generic rows, subordinate.
|
||||
poolSection
|
||||
|
||||
// (5) BY-SURFACE / TOP MODELS — tightened detail, below the pool.
|
||||
// (6) BY-SURFACE / TOP MODELS — tightened detail, below the pool.
|
||||
if let analytics = viewModel.analytics,
|
||||
BarAnalyticsView(analytics: analytics, section: .breakdown).hasBreakdown
|
||||
{
|
||||
@@ -161,6 +167,49 @@ struct BarMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Update available" banner. Shown when `viewModel.updateAvailable` is true.
|
||||
/// Styled to match the existing AlertRow / ErrorBanner patterns (tinted
|
||||
/// background card, section label, borderless button).
|
||||
@ViewBuilder private var updateBanner: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel("Update")
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.foregroundStyle(theme.accent)
|
||||
.font(.title3)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Update available")
|
||||
.font(.system(.caption, design: .default).weight(.semibold))
|
||||
if let v = viewModel.latestVersion {
|
||||
Text("CCS Bar \(v)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if viewModel.isInstallingUpdate {
|
||||
HStack(spacing: 4) {
|
||||
ProgressView().controlSize(.mini)
|
||||
Text("Updating...")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Button("Update Now") {
|
||||
viewModel.installUpdate()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(theme.accent)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(theme.accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
/// The cockpit. Native subscriptions (Claude Code / Codex) render as detailed
|
||||
/// `BarSubscriptionCard`s at the very top, ordered tightest-binding-first
|
||||
/// (closest to empty on top) so the window the user is about to run out of
|
||||
|
||||
@@ -11,6 +11,13 @@ import CCSBarCore
|
||||
struct BarPreferences {
|
||||
let defaults: UserDefaults
|
||||
|
||||
// MARK: - Update-check preference key
|
||||
|
||||
/// UserDefaults key for the auto-update-check toggle.
|
||||
static let autoCheckUpdatesKey = "ccsBar.autoCheckUpdates"
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
/// Default to the standard suite. A stable suite name is intentionally NOT used
|
||||
/// here because the rest of the app (MenuBarIcon) already persists to
|
||||
/// `.standard`; keeping one suite avoids split state across the two.
|
||||
@@ -18,6 +25,17 @@ struct BarPreferences {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
// MARK: - Update preference
|
||||
|
||||
/// Whether the app should periodically check for CCS Bar updates. Defaults to
|
||||
/// `true` — users who never open Settings still get update awareness.
|
||||
var autoCheckUpdates: Bool {
|
||||
get { defaults.bool(forKey: BarPreferences.autoCheckUpdatesKey) }
|
||||
nonmutating set { defaults.set(newValue, forKey: BarPreferences.autoCheckUpdatesKey) }
|
||||
}
|
||||
|
||||
// MARK: - Registration
|
||||
|
||||
/// Seed the registration domain so missing keys resolve to their real defaults
|
||||
/// rather than the type-zero value. Idempotent — safe to call on every launch.
|
||||
///
|
||||
@@ -30,6 +48,9 @@ struct BarPreferences {
|
||||
var d = BarAlertPrefsStore.registrationDefaults
|
||||
d[BarAlertPrefsStore.Key.dailyEnabled] = false
|
||||
d[BarAlertPrefsStore.Key.monthEnabled] = false
|
||||
// autoCheckUpdates defaults to true — registered here so the Bool key is
|
||||
// never absent (absent Bool reads as false, which would silently opt-out).
|
||||
d[BarPreferences.autoCheckUpdatesKey] = true
|
||||
defaults.register(defaults: d)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ struct BarPreferencesView: View {
|
||||
Form {
|
||||
appearanceSection
|
||||
glanceSection
|
||||
updatesSection
|
||||
quotaSection
|
||||
spendSection
|
||||
accountSection
|
||||
@@ -78,6 +79,20 @@ struct BarPreferencesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-update check toggle. Bound directly to the UserDefaults-backed pref
|
||||
/// via the same write-through pattern used for quota/spend toggles.
|
||||
private var updatesSection: some View {
|
||||
Section("Updates") {
|
||||
Toggle(
|
||||
"Check for CCS Bar updates automatically",
|
||||
isOn: Binding(
|
||||
get: { prefs.autoCheckUpdates },
|
||||
set: { prefs.autoCheckUpdates = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var quotaSection: some View {
|
||||
Section("Quota") {
|
||||
Toggle("Alert on low quota", isOn: $draft.quotaEnabled)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
@@ -42,6 +43,18 @@ final class BarViewModel: ObservableObject {
|
||||
/// dropdown so users who deny notifications still see the conditions.
|
||||
@Published var activeAlerts: [BarNotification] = []
|
||||
|
||||
// MARK: - Update state
|
||||
|
||||
/// Set to `true` when a newer published release is detected. Drives the
|
||||
/// "Update available" affordance in `BarMenuView`.
|
||||
@Published var updateAvailable: Bool = false
|
||||
/// The remote version string, e.g. `"1.8.0"`. Non-nil only while
|
||||
/// `updateAvailable` is true.
|
||||
@Published var latestVersion: String? = nil
|
||||
/// True while the installer process is being launched. Disables the button
|
||||
/// and shows a progress label so the user knows the action fired.
|
||||
@Published var isInstallingUpdate: Bool = false
|
||||
|
||||
private let home: String
|
||||
private var client: CCSBarClient?
|
||||
private var debouncer = RefreshDebouncer(interval: 15)
|
||||
@@ -52,6 +65,11 @@ final class BarViewModel: ObservableObject {
|
||||
private var pollTask: Task<Void, Never>?
|
||||
/// Guard against overlapping start-and-connect sequences.
|
||||
private var connectTask: Task<Void, Never>?
|
||||
/// Background update-check task — kept so it can be cancelled on deinit.
|
||||
private var updateCheckTask: Task<Void, Never>?
|
||||
/// Tracks when the last successful remote version check ran so we never
|
||||
/// hammer the release URL more than once every 6 hours.
|
||||
private var lastUpdateCheckDate: Date? = nil
|
||||
private let prefs: BarPreferences
|
||||
private let notifier: NotificationDelivering
|
||||
private let probe: BarServerProbe
|
||||
@@ -79,6 +97,7 @@ final class BarViewModel: ObservableObject {
|
||||
self.spendPeriod = SpendPeriodStore.load()
|
||||
reconnect()
|
||||
startBackgroundPolling()
|
||||
startUpdateChecking()
|
||||
}
|
||||
|
||||
// MARK: - Background polling
|
||||
@@ -99,6 +118,105 @@ final class BarViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update checking
|
||||
|
||||
/// Minimum interval between remote version checks (6 hours in seconds).
|
||||
private static let updateCheckInterval: TimeInterval = 6 * 60 * 60
|
||||
|
||||
/// Starts a long-lived background task that checks for updates once shortly
|
||||
/// after launch and then at most once every 6 hours, respecting the
|
||||
/// `autoCheckUpdates` preference. Never blocks the UI.
|
||||
private func startUpdateChecking() {
|
||||
updateCheckTask?.cancel()
|
||||
// Mirror startBackgroundPolling: weak capture, re-unwrap `self` inside the
|
||||
// loop each iteration so the closure never strongly retains the view model.
|
||||
updateCheckTask = Task { [weak self] in
|
||||
// Brief initial delay so launch completes before the first network call.
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
// First check fires immediately; subsequent ones wake every 30 minutes to
|
||||
// test the 6-hour gate, so a machine resuming from sleep re-checks without
|
||||
// a relaunch.
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
await self.runUpdateCheckIfNeeded()
|
||||
try? await Task.sleep(for: .seconds(30 * 60))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the remote version check when the preference is enabled and the
|
||||
/// 6-hour throttle permits. Safe to call from any context.
|
||||
private func runUpdateCheckIfNeeded() async {
|
||||
guard prefs.autoCheckUpdates else { return }
|
||||
if let last = lastUpdateCheckDate,
|
||||
Date().timeIntervalSince(last) < BarViewModel.updateCheckInterval
|
||||
{
|
||||
return
|
||||
}
|
||||
lastUpdateCheckDate = Date()
|
||||
await performUpdateCheck()
|
||||
}
|
||||
|
||||
/// Fetches the latest published version and updates `updateAvailable` /
|
||||
/// `latestVersion`. The network call is nonisolated; the published-state
|
||||
/// mutation happens back on the main actor (this class is @MainActor, so
|
||||
/// the assignment after `await` re-enters the main actor automatically).
|
||||
private func performUpdateCheck() async {
|
||||
// Hop off main actor for the network call so we don't block the UI.
|
||||
let latest = await Task.detached(priority: .utility) {
|
||||
await BarUpdateChecker.fetchLatestPublishedVersion()
|
||||
}.value
|
||||
|
||||
// Back on the main actor (implicit — this method is on @MainActor class).
|
||||
let current =
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
|
||||
if let v = latest, BarUpdateChecker.isNewer(v, than: current) {
|
||||
latestVersion = v
|
||||
updateAvailable = true
|
||||
} else {
|
||||
latestVersion = nil
|
||||
updateAvailable = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Launches `ccs bar install --launch --await-quit` as a detached process
|
||||
/// that survives this app terminating, then schedules a short-delay quit so
|
||||
/// the installer can swap the (locked) bundle while no app holds it.
|
||||
func installUpdate() {
|
||||
guard !isInstallingUpdate else { return }
|
||||
isInstallingUpdate = true
|
||||
|
||||
guard let bin = CCSBinaryResolver.resolve() else {
|
||||
print("[CCS Bar] installUpdate: ccs binary not found; cannot install update")
|
||||
isInstallingUpdate = false
|
||||
return
|
||||
}
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: bin)
|
||||
proc.arguments = ["bar", "install", "--launch", "--await-quit"]
|
||||
// Detach stdio so the process is not tied to this app's file descriptors.
|
||||
let null = FileHandle(forWritingAtPath: "/dev/null")
|
||||
proc.standardOutput = null
|
||||
proc.standardError = null
|
||||
proc.standardInput = null
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
// Do NOT call waitUntilExit — the installer must outlive us.
|
||||
} catch {
|
||||
print("[CCS Bar] installUpdate: failed to launch installer: \(error)")
|
||||
isInstallingUpdate = false
|
||||
return
|
||||
}
|
||||
|
||||
// Give the installer a moment to register, then quit so the bundle is
|
||||
// unlocked and the installer can swap + relaunch CCS Bar.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prefs
|
||||
|
||||
/// Re-read prefs after the preferences sheet writes through.
|
||||
|
||||
@@ -2,16 +2,16 @@ import AppKit
|
||||
import CCSBarCore
|
||||
import Foundation
|
||||
|
||||
/// Opens the CCS dashboard, starting the local server when it isn't running.
|
||||
///
|
||||
/// The dashboard is just a page served by the CCS web-server, so it can't load
|
||||
/// if no server is up. When the discovered URL isn't reachable we launch
|
||||
/// `ccs config`, which boots the server AND opens the dashboard in the browser
|
||||
/// itself — so we must not also open it (that would double-open a tab).
|
||||
enum DashboardLauncher {
|
||||
/// A GUI app does not inherit the shell PATH, so probe the common install
|
||||
/// locations for the `ccs` binary explicitly. First executable match wins.
|
||||
private static var ccsCandidates: [String] {
|
||||
// MARK: - Shared ccs binary resolver
|
||||
|
||||
/// Resolves the `ccs` binary path for GUI apps that don't inherit the shell
|
||||
/// PATH. Shared by `DashboardLauncher` and `BarViewModel` so the candidate
|
||||
/// list stays in one place (DRY).
|
||||
enum CCSBinaryResolver {
|
||||
/// Ordered list of candidate paths for the `ccs` binary. First executable
|
||||
/// match wins. GUI apps don't inherit the shell PATH, so we must enumerate
|
||||
/// common install locations explicitly.
|
||||
static var candidates: [String] {
|
||||
let home = NSHomeDirectory()
|
||||
return [
|
||||
"\(home)/.bun/bin/ccs",
|
||||
@@ -23,8 +23,24 @@ enum DashboardLauncher {
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns the path of the first executable `ccs` binary found, or `nil`
|
||||
/// when none of the candidates exist.
|
||||
static func resolve() -> String? {
|
||||
candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard launcher
|
||||
|
||||
/// Opens the CCS dashboard, starting the local server when it isn't running.
|
||||
///
|
||||
/// The dashboard is just a page served by the CCS web-server, so it can't load
|
||||
/// if no server is up. When the discovered URL isn't reachable we launch
|
||||
/// `ccs config`, which boots the server AND opens the dashboard in the browser
|
||||
/// itself — so we must not also open it (that would double-open a tab).
|
||||
enum DashboardLauncher {
|
||||
private static func ccsBinary() -> String? {
|
||||
ccsCandidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
CCSBinaryResolver.resolve()
|
||||
}
|
||||
|
||||
private static func dashboardURL() -> URL? {
|
||||
|
||||
@@ -1664,6 +1664,28 @@ do {
|
||||
check(port3000Count == 1, "probe: bar.json port 3000 deduped — probed once, not twice")
|
||||
}
|
||||
|
||||
// (BarUpdateChecker) Semver comparison drives the in-app "Update available"
|
||||
// affordance, so ordering must be numeric (1.10.0 > 1.9.0), tolerate
|
||||
// pre-release suffixes, and reject malformed input.
|
||||
do {
|
||||
check(BarUpdateChecker.isNewer("1.8.0", than: "1.7.0"), "isNewer: newer minor (1.8.0 > 1.7.0)")
|
||||
check(BarUpdateChecker.isNewer("1.7.1", than: "1.7.0"), "isNewer: newer patch (1.7.1 > 1.7.0)")
|
||||
check(BarUpdateChecker.isNewer("2.0.0", than: "1.9.9"), "isNewer: newer major (2.0.0 > 1.9.9)")
|
||||
check(
|
||||
BarUpdateChecker.isNewer("1.10.0", than: "1.9.0"),
|
||||
"isNewer: numeric not lexicographic (1.10.0 > 1.9.0)")
|
||||
check(!BarUpdateChecker.isNewer("1.7.0", than: "1.7.0"), "isNewer: equal is not newer")
|
||||
check(!BarUpdateChecker.isNewer("1.6.0", than: "1.7.0"), "isNewer: older is not newer")
|
||||
check(
|
||||
BarUpdateChecker.isNewer("1.8.0-beta.1", than: "1.7.0"),
|
||||
"isNewer: pre-release suffix stripped, core still newer")
|
||||
check(
|
||||
!BarUpdateChecker.isNewer("1.7.0-beta.1", than: "1.7.0"),
|
||||
"isNewer: equal core after stripping suffix is not newer")
|
||||
check(!BarUpdateChecker.isNewer("garbage", than: "1.7.0"), "isNewer: malformed latest rejected")
|
||||
check(!BarUpdateChecker.isNewer("1.7", than: "1.7.0"), "isNewer: fewer than 3 parts rejected")
|
||||
}
|
||||
|
||||
// cleanup
|
||||
try? FileManager.default.removeItem(atPath: tmp)
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight update checker for CCS Bar.
|
||||
///
|
||||
/// Self-contained: the fetch is the only side effect. `isNewer` is pure and
|
||||
/// unit-test-friendly with no bundle or network dependency.
|
||||
public enum BarUpdateChecker {
|
||||
|
||||
/// Published-version artifact URL. Stable redirect target maintained by the
|
||||
/// bar-release workflow; always points to the latest released version.txt.
|
||||
private static let versionURL = URL(
|
||||
string:
|
||||
"https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/version.txt"
|
||||
)!
|
||||
|
||||
/// Validates that a string looks like a semver (permissive: pre-release
|
||||
/// suffixes after `-` are allowed but optional).
|
||||
private static let semverRegex = try! NSRegularExpression(
|
||||
pattern: #"^\d+\.\d+\.\d+([-.0-9A-Za-z]*)?$"#
|
||||
)
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Fetches the latest published version string from the stable release URL.
|
||||
///
|
||||
/// - Returns: A trimmed semver string (e.g. `"1.8.0"`) when the remote
|
||||
/// returns HTTP 200 with a recognisable semver body; `nil` on any error,
|
||||
/// timeout, non-200 status, or invalid body.
|
||||
/// - Never throws: all errors are swallowed and surfaced as `nil` so callers
|
||||
/// never need a do/catch.
|
||||
public static func fetchLatestPublishedVersion() async -> String? {
|
||||
var request = URLRequest(url: versionURL)
|
||||
request.timeoutInterval = 8
|
||||
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
return nil
|
||||
}
|
||||
guard let body = String(data: data, encoding: .utf8) else { return nil }
|
||||
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard isValidSemver(trimmed) else { return nil }
|
||||
return trimmed
|
||||
} catch {
|
||||
// Network errors, timeouts, cancellation — all silently nil.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares two semver strings and returns `true` when `latest` is strictly
|
||||
/// newer than `current`.
|
||||
///
|
||||
/// - Strips any pre-release suffix (everything from the first `-` onward)
|
||||
/// before comparing. Compares major, minor, patch as `Int`s.
|
||||
/// - Returns `false` on any malformed input.
|
||||
public static func isNewer(_ latest: String, than current: String) -> Bool {
|
||||
guard
|
||||
let latestParts = parseMajorMinorPatch(latest),
|
||||
let currentParts = parseMajorMinorPatch(current)
|
||||
else { return false }
|
||||
|
||||
for (l, c) in zip(latestParts, currentParts) {
|
||||
if l > c { return true }
|
||||
if l < c { return false }
|
||||
}
|
||||
return false // equal
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private static func isValidSemver(_ s: String) -> Bool {
|
||||
let range = NSRange(s.startIndex..., in: s)
|
||||
return semverRegex.firstMatch(in: s, range: range) != nil
|
||||
}
|
||||
|
||||
/// Split a semver into [major, minor, patch], stripping any pre-release suffix.
|
||||
private static func parseMajorMinorPatch(_ version: String) -> [Int]? {
|
||||
// Strip pre-release suffix (anything from the first `-` onward).
|
||||
let core = version.split(separator: "-", maxSplits: 1).first.map(String.init) ?? version
|
||||
let parts = core.split(separator: ".").compactMap { Int($0) }
|
||||
guard parts.count >= 3 else { return nil }
|
||||
return [parts[0], parts[1], parts[2]]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.7.0
|
||||
1.8.0
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "8.5.0",
|
||||
"version": "8.5.0-dev.5",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
* Regression tests for the `ccs api create --cliproxy-provider claude` bridge path.
|
||||
* Regression tests for the `ccs api create --cliproxy-provider <provider>` bridge path.
|
||||
*
|
||||
* PR #1554 fixed the main CLIProxy env-builder to use the root URL for the built-in
|
||||
* claude provider. This file locks the same rule on the parallel api-create bridge path
|
||||
* The bridge path must use the same route rules as the main CLIProxy env-builder
|
||||
* (resolveCliproxyBridgeProfile / listCliproxyBridgeProviders) so both paths stay
|
||||
* consistent.
|
||||
* consistent across backends.
|
||||
*
|
||||
* Background: CLIProxyAPI registers /v1/messages at the ROOT. The /api/provider/<x>
|
||||
* prefix is a Plus-only route for non-Claude providers. Using /api/provider/claude
|
||||
* returns 404 on the base CLIProxyAPI installation.
|
||||
* Background: the original CLIProxyAPI backend registers Claude-compatible routes at
|
||||
* the root. The /api/provider/<x> prefix is a Plus-only route. Using provider-scoped
|
||||
* routes against the original backend returns 404.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
@@ -23,7 +22,7 @@ import { resolveCliproxyBridgeMetadata } from '../cliproxy-profile-bridge';
|
||||
import { invalidateConfigCache } from '../../../config/config-loader-facade';
|
||||
import { clearConfigCache } from '../../../cliproxy/config/base-config-loader';
|
||||
|
||||
describe('cliproxy-profile-bridge: claude provider uses root URL', () => {
|
||||
describe('cliproxy-profile-bridge: backend-aware provider route paths', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
@@ -37,12 +36,28 @@ describe('cliproxy-profile-bridge: claude provider uses root URL', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeBackendConfig(backend: 'original' | 'plus'): void {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
}
|
||||
|
||||
// ── resolveCliproxyBridgeProfile ──────────────────────────────────────────
|
||||
|
||||
it('resolveCliproxyBridgeProfile(claude) produces root base URL', () => {
|
||||
@@ -65,13 +80,20 @@ describe('cliproxy-profile-bridge: claude provider uses root URL', () => {
|
||||
expect(profile.models.haiku).toBe('');
|
||||
});
|
||||
|
||||
it('resolveCliproxyBridgeProfile(gemini) still uses scoped /api/provider path', () => {
|
||||
it('resolveCliproxyBridgeProfile(gemini) uses root URL on the original backend', () => {
|
||||
const profile = resolveCliproxyBridgeProfile('gemini');
|
||||
expect(profile.baseUrl).toContain('/api/provider/gemini');
|
||||
expect(profile.routePath).toBe('/api/provider/gemini');
|
||||
expect(profile.baseUrl).toBe('http://127.0.0.1:8317/');
|
||||
expect(profile.routePath).toBe('/');
|
||||
});
|
||||
|
||||
it('resolveCliproxyBridgeProfile(codex) still uses scoped /api/provider path', () => {
|
||||
it('resolveCliproxyBridgeProfile(codex) uses root URL on the original backend', () => {
|
||||
const profile = resolveCliproxyBridgeProfile('codex');
|
||||
expect(profile.baseUrl).toBe('http://127.0.0.1:8317/');
|
||||
expect(profile.routePath).toBe('/');
|
||||
});
|
||||
|
||||
it('resolveCliproxyBridgeProfile(codex) uses scoped path on the plus backend', () => {
|
||||
writeBackendConfig('plus');
|
||||
const profile = resolveCliproxyBridgeProfile('codex');
|
||||
expect(profile.baseUrl).toContain('/api/provider/codex');
|
||||
expect(profile.routePath).toBe('/api/provider/codex');
|
||||
@@ -87,7 +109,15 @@ describe('cliproxy-profile-bridge: claude provider uses root URL', () => {
|
||||
expect(claudeInfo?.routePath).not.toContain('/api/provider/claude');
|
||||
});
|
||||
|
||||
it('listCliproxyBridgeProviders keeps scoped routePaths for non-claude providers', () => {
|
||||
it('listCliproxyBridgeProviders shows root routePaths for original-backend providers', () => {
|
||||
const providers = listCliproxyBridgeProviders();
|
||||
for (const info of providers) {
|
||||
expect(info.routePath).toBe('/');
|
||||
}
|
||||
});
|
||||
|
||||
it('listCliproxyBridgeProviders keeps scoped routePaths for plus-backend non-claude providers', () => {
|
||||
writeBackendConfig('plus');
|
||||
const providers = listCliproxyBridgeProviders();
|
||||
for (const info of providers) {
|
||||
if (info.provider === 'claude') continue;
|
||||
|
||||
@@ -23,6 +23,8 @@ const ALLOWED_ANTHROPIC_ENV_KEYS = new Set<string>([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
// Written by profile-writer when --extra-models is supplied
|
||||
'ANTHROPIC_EXTRA_MODELS',
|
||||
...MODEL_ENV_KEYS,
|
||||
]);
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
loadConfigSafe,
|
||||
mutateConfig,
|
||||
} from '../../config/config-loader-facade';
|
||||
import { ProfileError } from '../../errors/error-types';
|
||||
|
||||
/** Check if URL is an OpenRouter endpoint */
|
||||
function isOpenRouterUrl(baseUrl: string): boolean {
|
||||
@@ -333,19 +334,27 @@ export function createCliproxyBridgeProfile(
|
||||
resolved.target,
|
||||
provider
|
||||
);
|
||||
const detectedBridge = resolveCliproxyBridgeMetadata({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: resolved.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: resolved.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
name: resolved.name,
|
||||
provider,
|
||||
target: resolved.target,
|
||||
cliproxyBridge:
|
||||
resolveCliproxyBridgeMetadata({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: resolved.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: resolved.apiKey,
|
||||
},
|
||||
}) ?? null,
|
||||
cliproxyBridge: detectedBridge ?? {
|
||||
provider,
|
||||
providerDisplayName: resolved.providerDisplayName,
|
||||
routePath: resolved.routePath,
|
||||
currentBaseUrl: resolved.baseUrl,
|
||||
source: resolved.source,
|
||||
usesCurrentTarget: true,
|
||||
usesCurrentAuthToken: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,7 +370,7 @@ export function updateApiProfileTarget(
|
||||
if (isUnifiedMode()) {
|
||||
mutateConfig((config) => {
|
||||
if (!config.profiles[name]) {
|
||||
throw new Error(`API profile not found: ${name}`);
|
||||
throw new ProfileError(`API profile not found: ${name}`, name);
|
||||
}
|
||||
|
||||
if (target === 'claude') {
|
||||
@@ -412,7 +421,7 @@ function removeApiProfileUnified(name: string): void {
|
||||
const profile = config.profiles[name];
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`API profile not found: ${name}`);
|
||||
throw new ProfileError(`API profile not found: ${name}`, name);
|
||||
}
|
||||
|
||||
if (profile.settings) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { initUI, color, dim, fail, info, ok } from '../../utils/ui';
|
||||
import { initUI, color, dim, info, ok } from '../../utils/ui';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import {
|
||||
@@ -44,7 +44,6 @@ export async function handleBackup(ctx: CommandContext, args: string[]): Promise
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log(`Usage: ${color('ccs auth backup <profile|default> [--json]', 'command')}`);
|
||||
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||
|
||||
@@ -53,7 +53,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log(
|
||||
`Usage: ${color('ccs auth create <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
|
||||
@@ -65,20 +64,21 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
}
|
||||
|
||||
if (!isValidAccountProfileName(profileName)) {
|
||||
const error =
|
||||
'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.';
|
||||
console.log(fail(error));
|
||||
console.log('');
|
||||
exitWithError(error, ExitCode.PROFILE_ERROR);
|
||||
exitWithError(
|
||||
'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.',
|
||||
ExitCode.PROFILE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
// Check if profile already exists (check both legacy and unified)
|
||||
const existsLegacy = ctx.registry.hasProfile(profileName);
|
||||
const existsUnified = ctx.registry.hasAccountUnified(profileName);
|
||||
if (!force && (existsLegacy || existsUnified)) {
|
||||
console.log(fail(`Profile already exists: ${profileName}`));
|
||||
console.log(` Use ${color('--force', 'command')} to overwrite`);
|
||||
exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR);
|
||||
// Keep the --force hint in the exitWithError message so it appears in the single output line
|
||||
exitWithError(
|
||||
`Profile already exists: ${profileName}\n Use --force to overwrite`,
|
||||
ExitCode.PROFILE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedName = sanitizeProfileNameForInstance(profileName);
|
||||
@@ -87,10 +87,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
);
|
||||
|
||||
if (collidingName) {
|
||||
const error = `Profile "${profileName}" conflicts with existing profile "${collidingName}" on filesystem.`;
|
||||
console.log(fail(error));
|
||||
console.log('');
|
||||
exitWithError(error, ExitCode.PROFILE_ERROR);
|
||||
exitWithError(
|
||||
`Profile "${profileName}" conflicts with existing profile "${collidingName}" on filesystem.`,
|
||||
ExitCode.PROFILE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedContext = resolveCreateAccountContext({
|
||||
@@ -100,8 +100,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
});
|
||||
|
||||
if (resolvedContext.error) {
|
||||
console.log(fail(resolvedContext.error));
|
||||
console.log('');
|
||||
exitWithError(resolvedContext.error, ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ export async function handleDefault(ctx: CommandContext, args: string[]): Promis
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log(`Usage: ${color('ccs auth default <profile>', 'command')}`);
|
||||
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||
|
||||
@@ -5,14 +5,8 @@
|
||||
*/
|
||||
|
||||
// Types and utilities
|
||||
export {
|
||||
AuthCommandArgs,
|
||||
ProfileOutput,
|
||||
ListOutput,
|
||||
CommandContext,
|
||||
parseArgs,
|
||||
formatRelativeTime,
|
||||
} from './types';
|
||||
export type { AuthCommandArgs, ProfileOutput, ListOutput, CommandContext } from './types';
|
||||
export { parseArgs, formatRelativeTime } from './types';
|
||||
|
||||
// Command handlers
|
||||
export { handleCreate } from './create-command';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { initUI, color, ok, fail, info } from '../../utils/ui';
|
||||
import { initUI, color, ok, info } from '../../utils/ui';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
|
||||
import { exitWithError } from '../../errors';
|
||||
@@ -26,7 +26,6 @@ export async function handleRemove(ctx: CommandContext, args: string[]): Promise
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log(`Usage: ${color('ccs auth remove <profile> [--yes]', 'command')}`);
|
||||
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||
@@ -37,7 +36,6 @@ export async function handleRemove(ctx: CommandContext, args: string[]): Promise
|
||||
const existsUnified = ctx.registry.hasAccountUnified(profileName);
|
||||
|
||||
if (!existsLegacy && !existsUnified) {
|
||||
console.log(fail(`Profile not found: ${profileName}`));
|
||||
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initUI, header, color, fail, ok, table } from '../../utils/ui';
|
||||
import { initUI, header, color, ok, table } from '../../utils/ui';
|
||||
import { resolveAccountContextPolicy } from '../account-context';
|
||||
import {
|
||||
isSharedResourceMode,
|
||||
@@ -30,7 +30,6 @@ export async function handleResources(ctx: CommandContext, args: string[]): Prom
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log(
|
||||
`Usage: ${color('ccs auth resources <profile> [--mode shared|profile-local] [--json]', 'command')}`
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { initUI, header, color, fail, table } from '../../utils/ui';
|
||||
import { initUI, header, color, table } from '../../utils/ui';
|
||||
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
|
||||
import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics';
|
||||
import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics';
|
||||
@@ -33,7 +33,6 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log(`Usage: ${color('ccs auth show <profile> [--json]', 'command')}`);
|
||||
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||
|
||||
@@ -8,7 +8,7 @@ import ProfileRegistry from '../profile-registry';
|
||||
import { InstanceManager } from '../../management/instance-manager';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { color, fail } from '../../utils/ui';
|
||||
import { color } from '../../utils/ui';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { formatRelativeTime } from '../../utils/time';
|
||||
@@ -100,7 +100,6 @@ export function rejectUnsupportedAuthOptions(
|
||||
}
|
||||
|
||||
const unknownList = unsupportedOptions.map((flag) => `"${flag}"`).join(', ');
|
||||
console.log(fail(`Unknown option(s): ${unknownList}`));
|
||||
console.log('');
|
||||
console.log(`Usage: ${color(options.usage, 'command')}`);
|
||||
console.log(`Help: ${color('ccs auth --help', 'command')}`);
|
||||
|
||||
@@ -376,13 +376,19 @@ export class ProfileRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default profile in unified config
|
||||
* Set default profile in unified config.
|
||||
* Accepts names from unified config (accounts, profiles, cliproxy variants)
|
||||
* as well as legacy profiles.json so that mixed-mode installs work correctly.
|
||||
*/
|
||||
setDefaultUnified(name: string): void {
|
||||
// Check legacy registry outside the mutate callback to avoid re-reading inside the transaction.
|
||||
const legacyData = this._read();
|
||||
const existsLegacy = !!legacyData.profiles[name];
|
||||
|
||||
mutateConfig((config) => {
|
||||
const exists =
|
||||
const existsUnified =
|
||||
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
|
||||
if (!exists) {
|
||||
if (!existsUnified && !existsLegacy) {
|
||||
throw new ProfileError(`Profile not found: ${name}`, name);
|
||||
}
|
||||
config.default = name;
|
||||
|
||||
+30
-24
@@ -46,31 +46,37 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase C: profile + target detection (extracted to dispatcher/profile-resolver.ts)
|
||||
const resolvedProfile = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger });
|
||||
const { profile, profileInfo, resolvedTarget, nativeClaudeRemainingArgs } = resolvedProfile;
|
||||
|
||||
// Dynamic imports needed by Phase E flows — preserve original load ordering.
|
||||
const InstanceManagerModule = await import('./management/instance-manager');
|
||||
const InstanceManager = InstanceManagerModule.default;
|
||||
const ProfileRegistryModule = await import('./auth/profile-registry');
|
||||
const ProfileRegistry = ProfileRegistryModule.default;
|
||||
const AccountContextModule = await import('./auth/account-context');
|
||||
const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule;
|
||||
const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance');
|
||||
const { resolveProfileContinuityInheritance } = ProfileContinuityModule;
|
||||
|
||||
// Build full dispatch context (Phase E)
|
||||
const dispatchCtx = {
|
||||
...resolvedProfile,
|
||||
InstanceManager,
|
||||
ProfileRegistry,
|
||||
resolveAccountContextPolicy,
|
||||
isAccountContextMetadata,
|
||||
resolveProfileContinuityInheritance,
|
||||
};
|
||||
|
||||
try {
|
||||
// Phase C: profile + target detection — inside try so ProfileNotFoundError
|
||||
// reaches the rich showProfileNotFound() handler below instead of falling
|
||||
// through to the bare unhandledRejection handler.
|
||||
const resolvedProfile = await resolveProfileAndTarget({
|
||||
args,
|
||||
browserLaunchOverride,
|
||||
cliLogger,
|
||||
});
|
||||
const { profile, profileInfo, resolvedTarget, nativeClaudeRemainingArgs } = resolvedProfile;
|
||||
|
||||
// Dynamic imports needed by Phase E flows — preserve original load ordering.
|
||||
const InstanceManagerModule = await import('./management/instance-manager');
|
||||
const InstanceManager = InstanceManagerModule.default;
|
||||
const ProfileRegistryModule = await import('./auth/profile-registry');
|
||||
const ProfileRegistry = ProfileRegistryModule.default;
|
||||
const AccountContextModule = await import('./auth/account-context');
|
||||
const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule;
|
||||
const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance');
|
||||
const { resolveProfileContinuityInheritance } = ProfileContinuityModule;
|
||||
|
||||
// Build full dispatch context (Phase E)
|
||||
const dispatchCtx = {
|
||||
...resolvedProfile,
|
||||
InstanceManager,
|
||||
ProfileRegistry,
|
||||
resolveAccountContextPolicy,
|
||||
isAccountContextMetadata,
|
||||
resolveProfileContinuityInheritance,
|
||||
};
|
||||
|
||||
// Special case: headless delegation (-p/--prompt)
|
||||
// Keep existing behavior for Claude targets only; non-claude targets must continue
|
||||
// through normal adapter dispatch logic.
|
||||
|
||||
@@ -132,9 +132,7 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
// Binary missing - download
|
||||
log('Binary not found, downloading...', verbose);
|
||||
|
||||
// Binary missing
|
||||
if (!config.allowInstall) {
|
||||
throw new Error(
|
||||
`${getBackendLabel(backend)} binary is not installed locally. ` +
|
||||
@@ -142,6 +140,9 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
|
||||
);
|
||||
}
|
||||
|
||||
// Only log download intent when a download will actually happen
|
||||
log('Binary not found, downloading...', verbose);
|
||||
|
||||
if (!config.forceVersion) {
|
||||
try {
|
||||
const latestVersion = await fetchLatestVersion(verbose, backend);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Supports 6 platforms: darwin/linux/windows x amd64/arm64
|
||||
*/
|
||||
|
||||
import {
|
||||
import type {
|
||||
PlatformInfo,
|
||||
SupportedOS,
|
||||
SupportedArch,
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { ensureProviderSettings, getEffectiveEnvVars } from '../env-builder';
|
||||
import { invalidateConfigCache } from '../../../config/config-loader-facade';
|
||||
import { clearConfigCache } from '../base-config-loader';
|
||||
|
||||
interface EnvSettings {
|
||||
ANTHROPIC_BASE_URL: string;
|
||||
@@ -30,14 +32,35 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-url-'));
|
||||
settingsPath = path.join(tempHome, 'codex.settings.json');
|
||||
process.env.CCS_HOME = tempHome;
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rewrites local root URL to provider endpoint without stripping codex effort suffixes', () => {
|
||||
function writeBackendConfig(backend: 'original' | 'plus'): void {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
}
|
||||
|
||||
it('keeps local root URL for the original backend without stripping codex effort suffixes', () => {
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
@@ -48,7 +71,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
});
|
||||
|
||||
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
@@ -63,7 +86,37 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
});
|
||||
|
||||
it('rewrites wrong local provider path to the requested provider', () => {
|
||||
it('rewrites local root URL to provider endpoint for the plus backend', () => {
|
||||
writeBackendConfig('plus');
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
|
||||
});
|
||||
|
||||
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex');
|
||||
});
|
||||
|
||||
it('rewrites wrong local provider path to root for the original backend', () => {
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
|
||||
});
|
||||
|
||||
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://localhost:8317');
|
||||
});
|
||||
|
||||
it('rewrites wrong local provider path to the requested provider for the plus backend', () => {
|
||||
writeBackendConfig('plus');
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
@@ -285,7 +338,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
});
|
||||
|
||||
it('repairs existing provider settings files that are missing env keys', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
||||
fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -308,7 +360,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
hooks?: Record<string, unknown>;
|
||||
};
|
||||
expect(repaired.hooks?.PreToolUse).toBeDefined();
|
||||
expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/agy');
|
||||
expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
|
||||
expect(repaired.env?.ANTHROPIC_MODEL).toBeDefined();
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeDefined();
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeDefined();
|
||||
@@ -316,7 +368,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
});
|
||||
|
||||
it('imports legacy cursor settings into the dedicated provider path without reusing legacy transport auth', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
writeBackendConfig('plus');
|
||||
const legacySettingsPath = path.join(tempHome, '.ccs', 'cursor.settings.json');
|
||||
const providerSettingsPath = path.join(
|
||||
tempHome,
|
||||
@@ -375,7 +427,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
});
|
||||
|
||||
it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
||||
fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -422,7 +473,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
});
|
||||
|
||||
it('preserves codex effort-suffixed IDs during ensureProviderSettings', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
|
||||
fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
@@ -458,6 +508,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
env?: Record<string, string>;
|
||||
presets?: Array<Record<string, string>>;
|
||||
};
|
||||
expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
|
||||
expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
@@ -469,7 +520,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
});
|
||||
|
||||
it('recovers malformed provider settings files by writing defaults and backup copy', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
||||
fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true });
|
||||
fs.writeFileSync(agySettingsPath, '{"env": {"ANTHROPIC_MODEL": "claude-sonnet-4-6-thinking",}');
|
||||
|
||||
@@ -36,6 +36,10 @@ import {
|
||||
getOutputLimitsEnv,
|
||||
getCcsDir,
|
||||
} from '../../config/config-loader-facade';
|
||||
import { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route';
|
||||
import { ConfigError } from '../../errors/error-types';
|
||||
|
||||
export { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route';
|
||||
|
||||
/** Settings file structure for user overrides */
|
||||
interface ProviderSettings {
|
||||
@@ -205,14 +209,12 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin
|
||||
|
||||
/**
|
||||
* Get environment variables for Claude CLI (bundled defaults)
|
||||
* Uses provider-specific endpoints (e.g., /api/provider/gemini) for explicit routing
|
||||
* except for the built-in claude provider.
|
||||
* Uses the backend-aware route path for the selected provider.
|
||||
*
|
||||
* Root-URL exception: the claude provider always uses the CLIProxy ROOT endpoint
|
||||
* (http://127.0.0.1:<port>) instead of /api/provider/claude. CLIProxyAPI's Claude Code
|
||||
* contract registers /v1/messages at the root; the /api/provider/ prefix is a Plus-only
|
||||
* feature for non-Claude providers. buildCliproxyProviderPath() encodes this rule and is
|
||||
* used here and by the api-create bridge path so both remain consistent.
|
||||
* Root-URL rule: the original CLIProxy backend uses the root endpoint
|
||||
* (http://127.0.0.1:<port>) and routes by model. The /api/provider/<x> prefix is
|
||||
* only used for Plus-backend non-Claude providers. buildCliproxyProviderPath()
|
||||
* encodes this rule and is reused by the api-create bridge path.
|
||||
*
|
||||
* For the claude built-in provider the model env vars are intentionally omitted so that
|
||||
* the user's own Claude Code /model selection is honored end-to-end (model-neutral passthrough).
|
||||
@@ -375,25 +377,6 @@ function ensureRequiredEnvVars(
|
||||
/** Localhost hostnames used for local CLIProxy endpoints */
|
||||
const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']);
|
||||
|
||||
/**
|
||||
* Return the CLIProxy route path for a provider.
|
||||
*
|
||||
* - claude uses the root path (empty string → "/" after buildProxyUrl normalises it)
|
||||
* because CLIProxyAPI's Claude Code contract registers /v1/messages at the root; the
|
||||
* /api/provider/ prefix is Plus-only and only for non-Claude providers.
|
||||
* - all other providers use the scoped /api/provider/<x> path.
|
||||
*
|
||||
* Exported so the profile-bridge can reuse the same rule (DRY).
|
||||
*/
|
||||
export function buildCliproxyProviderPath(provider: CLIProxyProvider): string {
|
||||
return provider === 'claude' ? '' : `/api/provider/${provider}`;
|
||||
}
|
||||
|
||||
function buildLocalProviderBaseUrl(provider: CLIProxyProvider, port: number): string {
|
||||
const rootUrl = `http://127.0.0.1:${port}`;
|
||||
return provider === 'claude' ? rootUrl : `${rootUrl}/api/provider/${provider}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize local CLIProxy endpoint to the expected provider route.
|
||||
* Only rewrites localhost URLs that target the active local port.
|
||||
@@ -415,11 +398,11 @@ function normalizeLocalProviderBaseUrl(
|
||||
: 80;
|
||||
if (!Number.isFinite(effectivePort) || effectivePort !== port) return baseUrl;
|
||||
|
||||
if (provider === 'claude') {
|
||||
const expectedPath = buildCliproxyProviderPath(provider);
|
||||
if (expectedPath === '') {
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
const expectedPath = `/api/provider/${provider}`;
|
||||
if (parsed.pathname === expectedPath && !parsed.search && !parsed.hash) return baseUrl;
|
||||
|
||||
parsed.pathname = expectedPath;
|
||||
@@ -437,7 +420,6 @@ function normalizeLocalProviderBaseUrl(
|
||||
*/
|
||||
function rewriteLocalhostUrls(
|
||||
envVars: NodeJS.ProcessEnv,
|
||||
provider: CLIProxyProvider,
|
||||
remoteConfig: RemoteProxyRewriteConfig
|
||||
): NodeJS.ProcessEnv {
|
||||
const result = { ...envVars };
|
||||
@@ -458,10 +440,7 @@ function rewriteLocalhostUrls(
|
||||
const standardWebPort = normalizedProtocol === 'https' ? 443 : 80;
|
||||
const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`;
|
||||
const remoteRootUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`;
|
||||
const remoteBaseUrl =
|
||||
provider === 'claude' ? remoteRootUrl : `${remoteRootUrl}/api/provider/${provider}`;
|
||||
|
||||
result.ANTHROPIC_BASE_URL = remoteBaseUrl;
|
||||
result.ANTHROPIC_BASE_URL = remoteRootUrl;
|
||||
|
||||
// Update auth token if provided
|
||||
if (remoteConfig.authToken) {
|
||||
@@ -517,7 +496,7 @@ export function getEffectiveEnvVars(
|
||||
envVars = ensureRequiredEnvVars(envVars, provider, port);
|
||||
// Apply remote rewrite if configured
|
||||
if (remoteRewriteConfig) {
|
||||
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
|
||||
envVars = rewriteLocalhostUrls(envVars, remoteRewriteConfig);
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
@@ -553,7 +532,7 @@ export function getEffectiveEnvVars(
|
||||
envVars = ensureRequiredEnvVars(envVars, provider, port);
|
||||
// Apply remote rewrite if configured
|
||||
if (remoteRewriteConfig) {
|
||||
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
|
||||
envVars = rewriteLocalhostUrls(envVars, remoteRewriteConfig);
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
@@ -696,10 +675,11 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
const value = JSON.parse(rawContent) as unknown;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('settings root must be an object');
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
parsed = value as Record<string, unknown>;
|
||||
} else {
|
||||
throw new SyntaxError('settings root must be an object');
|
||||
}
|
||||
parsed = value as Record<string, unknown>;
|
||||
} catch {
|
||||
// Preserve corrupt payload for manual inspection, then recover with defaults.
|
||||
const backupPath = `${settingsPath}.corrupt-${Date.now()}`;
|
||||
@@ -744,7 +724,7 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'claude' && typeof mergedEnv.ANTHROPIC_BASE_URL === 'string') {
|
||||
if (typeof mergedEnv.ANTHROPIC_BASE_URL === 'string') {
|
||||
const normalizedBaseUrl = normalizeLocalProviderBaseUrl(
|
||||
mergedEnv.ANTHROPIC_BASE_URL,
|
||||
provider,
|
||||
@@ -1002,7 +982,7 @@ export function getCompositeEnvVars(
|
||||
|
||||
// If default tier is missing, we cannot proceed meaningfully
|
||||
if (!defaultModel) {
|
||||
throw new Error(`Missing model for default tier '${defaultTier}'`);
|
||||
throw new ConfigError(`Missing model for default tier '${defaultTier}'`);
|
||||
}
|
||||
|
||||
// Determine base URL and auth token based on remote vs local mode
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||
import { DEFAULT_BACKEND } from '../binary/platform-detector';
|
||||
import type { CLIProxyBackend, CLIProxyProvider } from '../types';
|
||||
|
||||
export function getConfiguredCliproxyBackend(): CLIProxyBackend {
|
||||
try {
|
||||
const backend = loadOrCreateUnifiedConfig().cliproxy?.backend;
|
||||
return backend === 'plus' || backend === 'original' ? backend : DEFAULT_BACKEND;
|
||||
} catch {
|
||||
return DEFAULT_BACKEND;
|
||||
}
|
||||
}
|
||||
|
||||
export function usesScopedProviderRoutes(
|
||||
backend: CLIProxyBackend = getConfiguredCliproxyBackend()
|
||||
): boolean {
|
||||
return backend === 'plus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the CLIProxy route path for a provider.
|
||||
*
|
||||
* The original backend routes Claude-compatible traffic at the root and relies
|
||||
* on model-based provider selection. The Plus backend exposes provider-scoped
|
||||
* routes for non-Claude providers.
|
||||
*/
|
||||
export function buildCliproxyProviderPath(
|
||||
provider: CLIProxyProvider,
|
||||
backend: CLIProxyBackend = getConfiguredCliproxyBackend()
|
||||
): string {
|
||||
if (provider === 'claude') return '';
|
||||
return usesScopedProviderRoutes(backend) ? `/api/provider/${provider}` : '';
|
||||
}
|
||||
|
||||
export function buildLocalProviderBaseUrl(
|
||||
provider: CLIProxyProvider,
|
||||
port: number,
|
||||
backend: CLIProxyBackend = getConfiguredCliproxyBackend()
|
||||
): string {
|
||||
const rootUrl = `http://127.0.0.1:${port}`;
|
||||
return `${rootUrl}${buildCliproxyProviderPath(provider, backend)}`;
|
||||
}
|
||||
@@ -1,11 +1,27 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { buildClaudeEnvironment, resolveCliproxyImageAnalysisEnv } from '../env-resolver';
|
||||
import type { ImageAnalysisStatus } from '../../../utils/hooks';
|
||||
import { invalidateConfigCache } from '../../../config/config-loader-facade';
|
||||
import { clearConfigCache } from '../../config/base-config-loader';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
let originalCcsHome: string | undefined;
|
||||
let configHome = '';
|
||||
|
||||
function writeBackendConfig(backend: 'original' | 'plus'): void {
|
||||
const ccsDir = path.join(configHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
}
|
||||
|
||||
function createCodexSettingsFile(models: {
|
||||
defaultModel: string;
|
||||
@@ -70,7 +86,23 @@ function createImageAnalysisStatus(
|
||||
}
|
||||
|
||||
describe('buildClaudeEnvironment codex fallback normalization', () => {
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-resolver-home-'));
|
||||
tempDirs.push(configHome);
|
||||
process.env.CCS_HOME = configHome;
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
while (tempDirs.length > 0) {
|
||||
const tempDir = tempDirs.pop();
|
||||
if (tempDir) {
|
||||
@@ -99,6 +131,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => {
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex(xhigh)');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex(high)');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini(medium)');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
|
||||
});
|
||||
|
||||
it('keeps codex effort aliases when reasoning proxy is active', () => {
|
||||
@@ -122,6 +155,27 @@ describe('buildClaudeEnvironment codex fallback normalization', () => {
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444');
|
||||
});
|
||||
|
||||
it('keeps scoped codex reasoning URL when the plus backend is active', () => {
|
||||
writeBackendConfig('plus');
|
||||
const settingsPath = createCodexSettingsFile({
|
||||
defaultModel: 'gpt-5.3-codex-high',
|
||||
opusModel: 'gpt-5.3-codex-xhigh',
|
||||
sonnetModel: 'gpt-5.3-codex-high',
|
||||
haikuModel: 'gpt-5.4-mini-medium',
|
||||
});
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
customSettingsPath: settingsPath,
|
||||
codexReasoningPort: 9444,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex');
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import type { ProxyTarget } from '../proxy/proxy-target-resolver';
|
||||
import { getEffectiveApiKey } from '../auth/auth-token-manager';
|
||||
import { isSettings, type Settings } from '../../types/config';
|
||||
import { buildCliproxyProviderPath } from '../config/provider-route';
|
||||
|
||||
export interface RemoteProxyConfig {
|
||||
host: string;
|
||||
@@ -373,7 +374,10 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
||||
|
||||
if (codexReasoningPort) {
|
||||
// Codex reasoning proxy is the outermost layer for codex provider
|
||||
finalBaseUrl = `http://127.0.0.1:${codexReasoningPort}/api/provider/codex`;
|
||||
const providerPath = useRemoteProxy
|
||||
? '/api/provider/codex'
|
||||
: buildCliproxyProviderPath('codex');
|
||||
finalBaseUrl = `http://127.0.0.1:${codexReasoningPort}${providerPath}`;
|
||||
}
|
||||
|
||||
const effectiveEnvVars = {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { shouldDisableCodexReasoning } from './thinking-override-resolver';
|
||||
import type { CLIProxyProvider, ExecutorConfig, ResolvedProxyConfig } from '../types';
|
||||
import type { ThinkingConfig } from '../../config/unified-config-types';
|
||||
import { buildCliproxyProviderPath } from '../config/provider-route';
|
||||
|
||||
// ── Proxy constructor types (for dependency injection in tests) ───────────────
|
||||
|
||||
@@ -162,9 +163,10 @@ export async function buildProxyChain(context: ProxyChainContext): Promise<Proxy
|
||||
stripPathPrefix,
|
||||
});
|
||||
codexReasoningPort = await codexReasoningProxy.start();
|
||||
log(
|
||||
`Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex`
|
||||
);
|
||||
const providerPath = useRemoteProxy
|
||||
? '/api/provider/codex'
|
||||
: buildCliproxyProviderPath('codex');
|
||||
log(`Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}${providerPath}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
codexReasoningProxy = null;
|
||||
|
||||
@@ -196,14 +196,17 @@ async function resolveModelConfiguration(
|
||||
if (isOpenRouterUrl(baseUrl) && !providedModel) {
|
||||
console.log('');
|
||||
console.log(info('OpenRouter detected!'));
|
||||
const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', {
|
||||
default: true,
|
||||
});
|
||||
if (useInteractive) {
|
||||
const selection = await pickOpenRouterModel();
|
||||
if (selection) {
|
||||
openRouterModel = selection.model;
|
||||
openRouterTierMapping = selection.tierMapping;
|
||||
// --yes/-y suppresses interactive model browser; use preset defaultModel instead
|
||||
if (!yes) {
|
||||
const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', {
|
||||
default: true,
|
||||
});
|
||||
if (useInteractive) {
|
||||
const selection = await pickOpenRouterModel();
|
||||
if (selection) {
|
||||
openRouterModel = selection.model;
|
||||
openRouterTierMapping = selection.tierMapping;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
@@ -571,6 +574,17 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
|
||||
console.log(
|
||||
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
|
||||
);
|
||||
} else if (target === 'codex') {
|
||||
console.log(
|
||||
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses Codex CLI by default')}`
|
||||
);
|
||||
console.log(
|
||||
` ${color(`ccs-codex ${name} "your prompt"`, 'command')} ${dim('# explicit Codex alias')}`
|
||||
);
|
||||
console.log(` ${color(`ccsx ${name} "your prompt"`, 'command')} ${dim('# short alias')}`);
|
||||
console.log(
|
||||
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function showHelp(): Promise<void> {
|
||||
'Commands:',
|
||||
[
|
||||
['launch', 'Spawn the server detached, write ~/.ccs/bar.json, open the app (default)'],
|
||||
['serve', 'Run the CCS Bar server in the foreground (used internally by launch)'],
|
||||
['stop', 'Stop the detached CCS Bar server'],
|
||||
['status', 'Show whether the CCS Bar server is running'],
|
||||
['install', 'Download CCS Bar from the ccs-bar-latest GitHub release into ~/Applications'],
|
||||
@@ -33,6 +34,10 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
['--launch', 'Launch CCS Bar immediately after install without prompting'],
|
||||
['--no-launch', 'Skip the launch prompt after install'],
|
||||
[
|
||||
'--await-quit',
|
||||
'Wait for a running CCS Bar to quit, then swap and relaunch (used by the in-app updater)',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -66,6 +66,7 @@ export async function handleBarCommand(args: string[]): Promise<void> {
|
||||
if (!handler) {
|
||||
console.error(`[X] Unknown bar subcommand: ${subcommand}`);
|
||||
console.error('[i] Usage: ccs bar [launch|serve|stop|status|install|uninstall|version|--help]');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -490,9 +490,10 @@ export async function handleBarInstall(
|
||||
args: string[],
|
||||
deps: Partial<InstallDeps> = {}
|
||||
): Promise<void> {
|
||||
// Parse --launch / --no-launch flags before delegating to deps.
|
||||
// Parse --launch / --no-launch / --await-quit flags before delegating to deps.
|
||||
const forceLaunch = hasAnyFlag(args, ['--launch']);
|
||||
const noLaunch = hasAnyFlag(args, ['--no-launch']);
|
||||
const awaitQuit = hasAnyFlag(args, ['--await-quit']);
|
||||
|
||||
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
|
||||
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
|
||||
@@ -584,6 +585,36 @@ export async function handleBarInstall(
|
||||
return;
|
||||
}
|
||||
|
||||
// 3b-pre. --await-quit: if the app is running, poll until it exits before swapping.
|
||||
// Without this flag the existing behavior is preserved exactly (no change).
|
||||
if (awaitQuit) {
|
||||
const POLL_INTERVAL_MS = 300;
|
||||
const TIMEOUT_MS = 15_000;
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
|
||||
if (await isBarRunning()) {
|
||||
console.log('[i] Waiting for CCS Bar to quit before swapping...');
|
||||
|
||||
let exited = false;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
if (!(await isBarRunning())) {
|
||||
exited = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!exited) {
|
||||
console.error(
|
||||
'[!] CCS Bar is still running; quit it from the menu bar and re-run `ccs bar install`.'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
cleanupStaging();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. New bundle verified in staging — now safe to remove the old install.
|
||||
if (fs.existsSync(appPath)) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Tests for printControlPanelAccess: the Control Panel (API Management Center)
|
||||
* URL + login-key surface shown by `ccs cliproxy status` / `start`.
|
||||
*
|
||||
* Why this matters:
|
||||
* - The Control Panel login screen only asks for a "Management Key" with no
|
||||
* hint. Users who don't know the default (`ccs`) cannot get in. This surface
|
||||
* is the fix, so it must print the panel URL on the active port and the
|
||||
* EFFECTIVE key (default `ccs`, or the user's custom management_secret).
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
|
||||
import { printControlPanelAccess } from '../proxy-lifecycle-subcommand';
|
||||
import { invalidateConfigCache, mutateConfig } from '../../../config/config-loader-facade';
|
||||
|
||||
function createTestHome(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-control-panel-test-'));
|
||||
const ccsDir = path.join(dir, '.ccs');
|
||||
fs.mkdirSync(path.join(ccsDir, 'cliproxy', 'auth'), { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8');
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('printControlPanelAccess', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let logSpy: ReturnType<typeof spyOn>;
|
||||
let lines: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = createTestHome();
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
invalidateConfigCache();
|
||||
lines = [];
|
||||
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
||||
lines.push(String(msg ?? ''));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
invalidateConfigCache();
|
||||
try {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
});
|
||||
|
||||
it('prints the panel URL on the given port and the default key (ccs)', () => {
|
||||
printControlPanelAccess(8317);
|
||||
const out = lines.join('\n');
|
||||
expect(out).toContain('http://127.0.0.1:8317/management.html');
|
||||
expect(out).toContain('Panel login key:');
|
||||
expect(out).toContain('ccs');
|
||||
});
|
||||
|
||||
it('uses the active port in the URL', () => {
|
||||
printControlPanelAccess(9000);
|
||||
expect(lines.join('\n')).toContain('http://127.0.0.1:9000/management.html');
|
||||
});
|
||||
|
||||
it('prints a custom management_secret when configured', () => {
|
||||
mutateConfig((config) => {
|
||||
if (!config.cliproxy) {
|
||||
config.cliproxy = {};
|
||||
}
|
||||
if (!config.cliproxy.auth) {
|
||||
config.cliproxy.auth = {};
|
||||
}
|
||||
config.cliproxy.auth.management_secret = 'my-custom-key';
|
||||
});
|
||||
invalidateConfigCache();
|
||||
|
||||
printControlPanelAccess(8317);
|
||||
const out = lines.join('\n');
|
||||
expect(out).toContain('Panel login key:');
|
||||
expect(out).toContain('my-custom-key');
|
||||
});
|
||||
});
|
||||
@@ -53,9 +53,9 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
'Quota Management:',
|
||||
[
|
||||
['default <account>', 'Set default account for rotation'],
|
||||
['pause <account>', 'Pause account (skip in rotation)'],
|
||||
['resume <account>', 'Resume paused account'],
|
||||
['default <account> [--provider <name>]', 'Set default account for rotation'],
|
||||
['pause <account> [--provider <name>]', 'Pause account (skip in rotation)'],
|
||||
['resume <account> [--provider <name>]', 'Resume paused account'],
|
||||
[
|
||||
'quota',
|
||||
'Show quota status + pool context (drain order, per-account available/cooling/paused)',
|
||||
@@ -89,9 +89,12 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
['start', 'Start CLIProxy instance in background'],
|
||||
['restart', 'Restart CLIProxy instance'],
|
||||
['status', 'Show running CLIProxy status'],
|
||||
[
|
||||
'status [--verbose]',
|
||||
'Show CLIProxy status + Control Panel URL and login key (--verbose adds uptime)',
|
||||
],
|
||||
['stop', 'Stop running CLIProxy instance'],
|
||||
['doctor', 'Quota diagnostics and shared project detection'],
|
||||
['doctor | diag', 'Quota diagnostics and shared project detection'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -146,7 +146,12 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']);
|
||||
const command = remainingArgs[0];
|
||||
|
||||
if (hasAnyFlag(remainingArgs, ['--help', '-h'])) {
|
||||
// Show global cliproxy help whenever --help/-h appears (order-insensitive, so
|
||||
// `cliproxy status --help` and `cliproxy --verbose --help` both work). The one
|
||||
// exception is `routing affinity --help`, which renders affinity-specific help
|
||||
// after dispatch (handled below).
|
||||
const isRoutingAffinity = command === 'routing' && remainingArgs[1] === 'affinity';
|
||||
if (!isRoutingAffinity && hasAnyFlag(remainingArgs, ['--help', '-h'])) {
|
||||
await showHelp();
|
||||
return;
|
||||
}
|
||||
@@ -245,7 +250,7 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
start: async () => handleStart(verbose),
|
||||
stop: async () => handleStop(),
|
||||
restart: async () => handleRestart(verbose),
|
||||
status: async () => handleProxyStatus(),
|
||||
status: async () => handleProxyStatus(verbose),
|
||||
doctor: async () => handleDoctor(verbose),
|
||||
diag: async () => handleDoctor(verbose),
|
||||
default: async () => handleSetDefault(remainingArgs.slice(1)),
|
||||
|
||||
@@ -79,7 +79,8 @@ export async function handlePoolSubcommand(args: string[]): Promise<void> {
|
||||
console.log(warn(result.message));
|
||||
process.exitCode = 1;
|
||||
} else if (result.changed) {
|
||||
console.log(ok(result.message));
|
||||
// message already carries the correct [OK]/[!] prefix from routing-strategy
|
||||
console.log(result.message);
|
||||
} else {
|
||||
console.log(info(result.message));
|
||||
}
|
||||
@@ -93,7 +94,8 @@ export async function handlePoolSubcommand(args: string[]): Promise<void> {
|
||||
console.log(warn(result.message));
|
||||
process.exitCode = 1;
|
||||
} else if (result.changed) {
|
||||
console.log(ok(result.message));
|
||||
// message already carries the correct [OK]/[!] prefix from routing-strategy
|
||||
console.log(result.message);
|
||||
} else {
|
||||
console.log(info(result.message));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,23 @@ import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui';
|
||||
import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services';
|
||||
import { detectRunningProxy } from '../../cliproxy/proxy/proxy-detector';
|
||||
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
|
||||
import { getEffectiveManagementSecret } from '../../cliproxy/auth/auth-token-manager';
|
||||
|
||||
/**
|
||||
* Print how to reach the local CLIProxy Control Panel (a.k.a. API Management
|
||||
* Center) and the key needed to log in.
|
||||
*
|
||||
* The panel is served by CLIProxy at `/management.html` on the proxy port and
|
||||
* its login is gated by the management secret (default `ccs`). The login screen
|
||||
* only asks for a "Management Key" with no hint, so users frequently cannot get
|
||||
* in. Surfacing the URL + resolved key here removes that guesswork.
|
||||
*/
|
||||
export function printControlPanelAccess(port: number): void {
|
||||
const secret = getEffectiveManagementSecret();
|
||||
console.log('');
|
||||
console.log(` Control Panel: http://127.0.0.1:${port}/management.html`);
|
||||
console.log(` Panel login key: ${secret}`);
|
||||
}
|
||||
|
||||
export async function handleStart(verbose = false): Promise<void> {
|
||||
await initUI();
|
||||
@@ -30,6 +47,9 @@ export async function handleStart(verbose = false): Promise<void> {
|
||||
console.log(ok(`CLIProxy started on port ${result.port}`));
|
||||
}
|
||||
console.log(dim('To stop: ccs cliproxy stop'));
|
||||
if (result.port) {
|
||||
printControlPanelAccess(result.port);
|
||||
}
|
||||
} else {
|
||||
console.log(warn(result.error || 'Failed to start CLIProxy'));
|
||||
}
|
||||
@@ -66,7 +86,7 @@ export async function handleRestart(verbose = false): Promise<void> {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export async function handleProxyStatus(): Promise<void> {
|
||||
export async function handleProxyStatus(verbose = false): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('CLIProxy Status'));
|
||||
console.log('');
|
||||
@@ -80,9 +100,20 @@ export async function handleProxyStatus(): Promise<void> {
|
||||
console.log(` Sessions: ${status.sessionCount || 0} active`);
|
||||
if (status.startedAt) {
|
||||
console.log(` Started: ${new Date(status.startedAt).toLocaleString()}`);
|
||||
if (verbose) {
|
||||
const uptimeMs = Date.now() - new Date(status.startedAt).getTime();
|
||||
const uptimeSec = Math.floor(uptimeMs / 1000);
|
||||
const h = Math.floor(uptimeSec / 3600);
|
||||
const m = Math.floor((uptimeSec % 3600) / 60);
|
||||
const s = uptimeSec % 60;
|
||||
const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
console.log(` Uptime: ${uptimeStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(dim('To stop: ccs cliproxy stop'));
|
||||
printControlPanelAccess(status.port ?? port);
|
||||
} else {
|
||||
// Fallback: detect untracked/orphaned proxy process (e.g. detached session without lock file).
|
||||
const detected = await detectRunningProxy(port);
|
||||
@@ -94,8 +125,12 @@ export async function handleProxyStatus(): Promise<void> {
|
||||
if (!detected.sessionCount) {
|
||||
console.log(dim(' Note: Detected running proxy without local session lock'));
|
||||
}
|
||||
if (verbose) {
|
||||
console.log(dim(' Note: Process detected via port scan; no local lock file present'));
|
||||
}
|
||||
console.log('');
|
||||
console.log(dim('To stop: ccs cliproxy stop'));
|
||||
printControlPanelAccess(port);
|
||||
} else {
|
||||
console.log(` Status: ${color('Not running', 'warning')}`);
|
||||
console.log('');
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as path from 'path';
|
||||
import { initUI, header, color, dim, ok, fail, warn, info } from '../../utils/ui';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
|
||||
import { ConfigError } from '../../errors';
|
||||
import {
|
||||
formatDisplayPath,
|
||||
getClaudeSettingsDisplayPath,
|
||||
@@ -86,6 +87,14 @@ export async function createBackup(): Promise<string> {
|
||||
throw new Error('No settings.json to backup');
|
||||
}
|
||||
|
||||
// Guard: refuse to read a symlinked settings.json (TOCTOU mitigation on backup read).
|
||||
if (await isSymlinkAsync(settingsPath)) {
|
||||
throw new ConfigError(
|
||||
'settings.json is a symlink - refusing to read for backup for security',
|
||||
settingsPath
|
||||
);
|
||||
}
|
||||
|
||||
const settingsContent = await readFileUtf8NoFollow(settingsPath);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
startOpenAICompatProxy,
|
||||
stopOpenAICompatProxy,
|
||||
} from '../proxy';
|
||||
import { loadSettings } from '../config/config-loader-facade';
|
||||
import { loadConfigSafe, loadSettings } from '../config/config-loader-facade';
|
||||
import { ProfileError } from '../errors';
|
||||
|
||||
function parseOptionValue(args: string[], key: string): string | undefined {
|
||||
const exactIndex = args.findIndex((arg) => arg === key);
|
||||
@@ -82,11 +83,22 @@ function showHelp(): number {
|
||||
}
|
||||
|
||||
function resolveProfile(profileName: string) {
|
||||
// Pre-check profile in config to avoid the generic error() + npm-install hint from getSettingsPath.
|
||||
const config = loadConfigSafe();
|
||||
if (!config.profiles[profileName]) {
|
||||
throw new ProfileError(
|
||||
`Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`,
|
||||
profileName
|
||||
);
|
||||
}
|
||||
const settingsPath = expandPath(getSettingsPath(profileName));
|
||||
const settings = loadSettings(settingsPath);
|
||||
const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, settings.env || {});
|
||||
if (!profile) {
|
||||
throw new Error(`Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`);
|
||||
throw new ProfileError(
|
||||
`Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`,
|
||||
profileName
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,16 @@ async function selectOption(
|
||||
export function isFirstTimeInstall(): boolean {
|
||||
// Check unified config first (config.yaml)
|
||||
if (hasUnifiedConfig()) {
|
||||
const loaded = loadUnifiedConfig();
|
||||
let loaded: ReturnType<typeof loadUnifiedConfig>;
|
||||
try {
|
||||
loaded = loadUnifiedConfig();
|
||||
} catch {
|
||||
// Config exists but is corrupted/unparseable — treat as not first-time so we
|
||||
// don't run the setup wizard; the YAML error was already printed by loadUnifiedConfig.
|
||||
console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted'));
|
||||
console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose'));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Config exists but is corrupted/invalid - don't treat as first-time
|
||||
if (loaded === null) {
|
||||
|
||||
@@ -141,6 +141,13 @@ export async function handleTokensCommand(args: string[]): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --variant without --api-key is a misuse — it has no effect without a key to assign.
|
||||
if (variantValue !== undefined && !hasApiKeyFlag) {
|
||||
console.error(fail('--variant requires --api-key'));
|
||||
console.error(info('Usage: ccs tokens --variant <name> --api-key <key>'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
|
||||
if (regenerateSecretFlag) {
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
import type { UnifiedConfig } from './unified-config-types';
|
||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||
|
||||
// loadUnifiedConfig() runs several times per invocation (startup migration check,
|
||||
// first-time-install check, command handler, doctor checks). A corrupt config.yaml
|
||||
// must report its parse error ONCE, not once per call. Track paths already warned
|
||||
// so repeated loads in the same process stay quiet.
|
||||
const warnedCorruptConfigPaths = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 1 re-exports: io-locks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -167,22 +173,27 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
|
||||
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
// U3: Provide better context for YAML syntax errors
|
||||
if (err instanceof yaml.YAMLException) {
|
||||
const mark = err.mark;
|
||||
console.error(`[X] YAML syntax error in ${yamlPath}:`);
|
||||
console.error(
|
||||
` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}`
|
||||
);
|
||||
if (mark?.snippet) {
|
||||
console.error(` ${mark.snippet}`);
|
||||
// U3: Provide better context for YAML syntax errors. Print at most once per
|
||||
// path per process so repeated loads of a corrupt file do not spam the error.
|
||||
const alreadyWarned = warnedCorruptConfigPaths.has(yamlPath);
|
||||
if (!alreadyWarned) {
|
||||
warnedCorruptConfigPaths.add(yamlPath);
|
||||
if (err instanceof yaml.YAMLException) {
|
||||
const mark = err.mark;
|
||||
console.error(`[X] YAML syntax error in ${yamlPath}:`);
|
||||
console.error(
|
||||
` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}`
|
||||
);
|
||||
if (mark?.snippet) {
|
||||
console.error(` ${mark.snippet}`);
|
||||
}
|
||||
console.error(
|
||||
` Tip: Check for missing colons, incorrect indentation, or unquoted special characters.`
|
||||
);
|
||||
} else {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(`[X] Failed to load config: ${error}`);
|
||||
}
|
||||
console.error(
|
||||
` Tip: Check for missing colons, incorrect indentation, or unquoted special characters.`
|
||||
);
|
||||
} else {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(`[X] Failed to load config: ${error}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -68,10 +68,19 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
|
||||
}
|
||||
|
||||
// Auto-migrate to unified config format (silent if already migrated)
|
||||
// Skip if user is explicitly running migrate command
|
||||
// Skip if user is explicitly running migrate command.
|
||||
// Wrapped in try-catch so a corrupt config.yaml does not crash pre-dispatch
|
||||
// before doctor/setup can run and report the problem to the user.
|
||||
if (firstArg !== 'migrate') {
|
||||
const { autoMigrate } = await import('../config/migration-manager');
|
||||
await autoMigrate();
|
||||
try {
|
||||
const { autoMigrate } = await import('../config/migration-manager');
|
||||
await autoMigrate();
|
||||
} catch (err) {
|
||||
cliLogger.warn('migration.failed', 'Auto-migration failed (config may be corrupt)', {
|
||||
message: (err as Error).message,
|
||||
});
|
||||
// Do not print the error again — loadUnifiedConfig already printed the YAML details.
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-recovery for missing configuration (BEFORE any early-exit commands)
|
||||
@@ -90,8 +99,10 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
|
||||
cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', {
|
||||
message: (err as Error).message,
|
||||
});
|
||||
// Recovery is best-effort - don't block basic CLI functionality
|
||||
console.warn('[!] Recovery failed:', (err as Error).message);
|
||||
// Recovery is best-effort - don't block basic CLI functionality. Use only the
|
||||
// first line: a YAML parse error embeds a multi-line snippet the loader already
|
||||
// printed, so re-emitting it here just duplicates the noise.
|
||||
console.warn('[!] Recovery failed:', (err as Error).message.split('\n')[0].trim());
|
||||
}
|
||||
|
||||
// Root command router (handles --help, --version, config, doctor, etc.)
|
||||
@@ -111,13 +122,14 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
|
||||
return true;
|
||||
}
|
||||
|
||||
// Special case: copilot command (GitHub Copilot integration)
|
||||
// Route known subcommands to command handler, keep all other args as profile passthrough.
|
||||
// Special case: copilot command (GitHub Copilot integration).
|
||||
// Route ONLY known subcommands to the handler; any other arg is kept as
|
||||
// passthrough to the copilot bridge profile flow (legacy behavior). Do not
|
||||
// treat an arbitrary token as an unknown subcommand error, or this breaks the
|
||||
// passthrough path.
|
||||
if (firstArg === 'copilot' && args.length > 1) {
|
||||
const copilotToken = args[1];
|
||||
const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken);
|
||||
|
||||
if (shouldRouteToCopilotCommand) {
|
||||
if (isCopilotSubcommandToken(copilotToken)) {
|
||||
const { handleCopilotCommand } = await import('../commands/copilot-command');
|
||||
const exitCode = await handleCopilotCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
@@ -125,11 +137,12 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
|
||||
}
|
||||
|
||||
// Special case: explicit legacy Cursor bridge namespace.
|
||||
// Route ONLY known subcommands; keep other args as passthrough to the cursor
|
||||
// bridge flow (legacy behavior).
|
||||
if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) {
|
||||
const { handleCursorCommand } = await import('../commands/cursor-command');
|
||||
const cursorToken = args[1];
|
||||
|
||||
if (isCursorSubcommandToken(cursorToken)) {
|
||||
const { handleCursorCommand } = await import('../commands/cursor-command');
|
||||
const exitCode = await handleCursorCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,26 @@ export class CLIProxyAuthChecker implements IHealthChecker {
|
||||
name = 'CLIProxy Auth';
|
||||
|
||||
run(results: HealthCheck): void {
|
||||
const authStatuses = getAllAuthStatus();
|
||||
// Reading auth status touches ~/.ccs; a permission error (EACCES) must not
|
||||
// crash doctor before the ERRORS section renders. Surface it as a recoverable
|
||||
// check instead of throwing.
|
||||
let authStatuses;
|
||||
try {
|
||||
authStatuses = getAllAuthStatus();
|
||||
} catch (err) {
|
||||
const spinner = ora('Checking CLIProxy auth').start();
|
||||
spinner.fail();
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.log(` ${warn('CLIProxy Auth'.padEnd(22))} Unable to read auth status`);
|
||||
results.addCheck(
|
||||
'CLIProxy Auth',
|
||||
'error',
|
||||
`Unable to read CLIProxy auth status: ${message}`,
|
||||
'sudo chown -R $USER ~/.ccs ~/.claude && chmod -R u+rwX ~/.ccs ~/.claude',
|
||||
{ status: 'ERROR', info: 'Auth status unavailable (permission or read error)' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const status of authStatuses) {
|
||||
const spinner = ora(`Checking ${status.provider} auth`).start();
|
||||
const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1);
|
||||
|
||||
@@ -72,10 +72,13 @@ export class ConfigFilesChecker implements IHealthChecker {
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(` ${fail('config.yaml'.padEnd(22))} Invalid YAML`);
|
||||
// First line only: the parser message embeds a multi-line snippet the
|
||||
// loader already printed, so keep the ERRORS entry to a single line.
|
||||
const reason = (e as Error).message.split('\n')[0].trim();
|
||||
results.addCheck(
|
||||
'config.yaml',
|
||||
'error',
|
||||
`Invalid YAML: ${(e as Error).message}`,
|
||||
`Invalid YAML: ${reason}`,
|
||||
`Backup and recreate: mv ${configYamlPath} ${configYamlPath}.backup && npm install -g @kaitranntt/ccs --force`,
|
||||
{ status: 'ERROR', info: 'Invalid YAML' }
|
||||
);
|
||||
|
||||
@@ -40,19 +40,14 @@ export class ProfilesChecker implements IHealthChecker {
|
||||
return;
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(
|
||||
` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${(e as Error).message}`
|
||||
);
|
||||
results.addCheck(
|
||||
'Profiles',
|
||||
'error',
|
||||
`Invalid config.yaml: ${(e as Error).message}`,
|
||||
undefined,
|
||||
{
|
||||
status: 'ERROR',
|
||||
info: (e as Error).message,
|
||||
}
|
||||
);
|
||||
// First line only: a YAML parse error embeds a multi-line snippet (already
|
||||
// printed by the loader) that otherwise leaks into the summary table cell.
|
||||
const reason = (e as Error).message.split('\n')[0].trim();
|
||||
console.log(` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${reason}`);
|
||||
results.addCheck('Profiles', 'error', `Invalid config.yaml: ${reason}`, undefined, {
|
||||
status: 'ERROR',
|
||||
info: reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -65,19 +60,12 @@ export class ProfilesChecker implements IHealthChecker {
|
||||
return;
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(
|
||||
` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${(e as Error).message}`
|
||||
);
|
||||
results.addCheck(
|
||||
'Profiles',
|
||||
'error',
|
||||
`Invalid config.json: ${(e as Error).message}`,
|
||||
undefined,
|
||||
{
|
||||
status: 'ERROR',
|
||||
info: (e as Error).message,
|
||||
}
|
||||
);
|
||||
const reason = (e as Error).message.split('\n')[0].trim();
|
||||
console.log(` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${reason}`);
|
||||
results.addCheck('Profiles', 'error', `Invalid config.json: ${reason}`, undefined, {
|
||||
status: 'ERROR',
|
||||
info: reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,49 +46,71 @@ class Doctor {
|
||||
|
||||
// Group 1: System
|
||||
console.log(header('SYSTEM'));
|
||||
await runSystemChecks(this.results);
|
||||
await this.safe('System Checks', () => runSystemChecks(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 2: Environment (OAuth readiness diagnostics)
|
||||
console.log(header('ENVIRONMENT'));
|
||||
runEnvironmentCheck(this.results);
|
||||
await this.safe('Environment Check', () => runEnvironmentCheck(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 3: Configuration
|
||||
console.log(header('CONFIGURATION'));
|
||||
runConfigChecks(this.results);
|
||||
this.runDockerKeyRotationCheck();
|
||||
await this.safe('Configuration Checks', () => runConfigChecks(this.results));
|
||||
await this.safe('Docker Key Rotation', () => this.runDockerKeyRotationCheck());
|
||||
console.log('');
|
||||
|
||||
// Group 4: Profiles & Delegation
|
||||
console.log(header('PROFILES & DELEGATION'));
|
||||
runProfileChecks(this.results);
|
||||
await this.safe('Profile Checks', () => runProfileChecks(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 5: System Health
|
||||
console.log(header('SYSTEM HEALTH'));
|
||||
runSymlinkChecks(this.results);
|
||||
await this.safe('Symlink Checks', () => runSymlinkChecks(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 6: CLIProxy Plus (OAuth profiles)
|
||||
console.log(header('CLIPROXY PLUS (OAUTH PROFILES)'));
|
||||
await runCLIProxyChecks(this.results);
|
||||
await this.safe('CLIProxy Checks', () => runCLIProxyChecks(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 7: OAuth Readiness (port availability)
|
||||
console.log(header('OAUTH READINESS'));
|
||||
await runOAuthChecks(this.results);
|
||||
await this.safe('OAuth Checks', () => runOAuthChecks(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 8: Image Analysis Config
|
||||
console.log(header('IMAGE ANALYSIS'));
|
||||
await runImageAnalysisCheck(this.results);
|
||||
await this.safe('Image Analysis Check', () => runImageAnalysisCheck(this.results));
|
||||
console.log('');
|
||||
|
||||
this.showReport();
|
||||
return this.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one check group defensively. A thrown check (e.g. a corrupt config.yaml
|
||||
* that makes a loader throw) is recorded as an error instead of aborting the
|
||||
* whole run, so the SUMMARY and ERRORS sections (with recovery hints) always
|
||||
* render.
|
||||
*/
|
||||
private async safe(label: string, fn: () => void | Promise<void>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
// Use only the first line of the message: parser errors (e.g. js-yaml)
|
||||
// embed a multi-line snippet that the loader already printed once, so the
|
||||
// ERRORS section stays one line per failed check.
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const message = raw.split('\n')[0].trim();
|
||||
this.results.addCheck(label, 'error', message, undefined, {
|
||||
status: 'ERROR',
|
||||
info: 'check failed (see ERRORS)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show health check report
|
||||
*/
|
||||
|
||||
@@ -101,6 +101,9 @@ interface OpenAIMessage {
|
||||
export interface ProxyOpenAIRequest {
|
||||
model?: string;
|
||||
stream: boolean;
|
||||
stream_options?: {
|
||||
include_usage: boolean;
|
||||
};
|
||||
reasoning_effort?: string;
|
||||
reasoning?: {
|
||||
enabled: boolean;
|
||||
@@ -728,6 +731,16 @@ export class ProxyRequestTransformer {
|
||||
? source.model.trim()
|
||||
: undefined,
|
||||
stream: source.stream === true,
|
||||
// Ask the upstream provider to emit a final SSE chunk with token usage.
|
||||
// OpenAI-compatible endpoints (and most proxies that follow the spec,
|
||||
// including LiteLLM) only include `usage` in the streamed response when
|
||||
// `stream_options.include_usage` is set on the request. Without this
|
||||
// flag the proxy's stream parser still writes Anthropic-shaped events
|
||||
// with `usage: { input_tokens: 0, output_tokens: 0 }`, so Claude Code's
|
||||
// status line and the session log show 0 tokens even though the call
|
||||
// was billed. See:
|
||||
// https://platform.openai.com/docs/api-reference/chat-streaming
|
||||
...(source.stream === true ? { stream_options: { include_usage: true } } : {}),
|
||||
messages: coalesceMessages(allMessages),
|
||||
max_tokens: asNumber(source.max_tokens),
|
||||
temperature: asNumber(source.temperature),
|
||||
|
||||
@@ -12,6 +12,7 @@ export const PROVIDER_PRESET_IDS = [
|
||||
'openrouter',
|
||||
'alibaba-coding-plan',
|
||||
'huggingface',
|
||||
'tuningengines',
|
||||
'ollama',
|
||||
'llamacpp',
|
||||
'anthropic',
|
||||
@@ -62,6 +63,8 @@ export const PROVIDER_PRESET_ALIASES: Readonly<Record<string, ProviderPresetId>>
|
||||
alibaba: 'alibaba-coding-plan',
|
||||
acp: 'alibaba-coding-plan',
|
||||
hf: 'huggingface',
|
||||
te: 'tuningengines',
|
||||
tuning: 'tuningengines',
|
||||
});
|
||||
|
||||
const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
|
||||
@@ -154,6 +157,20 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
|
||||
defaultTarget: 'droid',
|
||||
badge: 'Router',
|
||||
},
|
||||
{
|
||||
id: 'tuningengines',
|
||||
name: 'Tuning Engines',
|
||||
description: 'OpenAI-compatible chat completions gateway',
|
||||
baseUrl: 'https://api.tuningengines.com/v1',
|
||||
defaultProfileName: 'te',
|
||||
defaultModel: 'gpt-4o',
|
||||
apiKeyPlaceholder: 'sk-te-...',
|
||||
apiKeyHint: 'Create an inference key at app.tuningengines.com/inference/keys',
|
||||
category: 'alternative',
|
||||
requiresApiKey: true,
|
||||
defaultTarget: 'droid',
|
||||
badge: 'Gateway',
|
||||
},
|
||||
{
|
||||
id: 'glm',
|
||||
name: 'GLM',
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from '../web-server/services/compatible-cli-toml-file-service';
|
||||
import { getModelMaxLevel } from '../cliproxy/model-catalog';
|
||||
import { parseCodexModelTuningAlias } from '../cliproxy/ai-providers/model-id-normalizer';
|
||||
import { buildLocalProviderBaseUrl } from '../cliproxy/config/provider-route';
|
||||
import { ConfigError } from '../errors/error-types';
|
||||
|
||||
export const CCSXP_CLIPROXY_SHORTCUT_ENV = 'CCSXP_CLIPROXY_SHORTCUT';
|
||||
export const CODEX_CLIPROXY_PROVIDER_ID = 'cliproxy';
|
||||
@@ -37,7 +39,7 @@ function resolveCodexConfigPath(env: NodeJS.ProcessEnv = process.env): {
|
||||
}
|
||||
|
||||
export function buildCodexCliproxyProviderBaseUrl(port: number): string {
|
||||
return `http://127.0.0.1:${port}/api/provider/codex`;
|
||||
return buildLocalProviderBaseUrl('codex', port);
|
||||
}
|
||||
|
||||
export function isCcsxpCliproxyShortcut(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
@@ -70,10 +72,66 @@ function resolveProviderEnvKey(provider: Record<string, unknown> | null): string
|
||||
return CODEX_CLIPROXY_PROVIDER_ENV_KEY;
|
||||
}
|
||||
|
||||
function isProviderReady(provider: Record<string, unknown>, envKey: string): boolean {
|
||||
const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']);
|
||||
|
||||
function getEffectiveUrlPort(url: URL): number {
|
||||
if (url.port) return Number.parseInt(url.port, 10);
|
||||
return url.protocol === 'https:' ? 443 : 80;
|
||||
}
|
||||
|
||||
function isManagedLocalUrl(url: URL): boolean {
|
||||
return ['http:', 'https:'].includes(url.protocol) && LOCALHOST_NAMES.has(url.hostname);
|
||||
}
|
||||
|
||||
function shouldUseManagedLocalBaseUrl(current: URL, expected: URL): boolean {
|
||||
if (!isManagedLocalUrl(current) || !isManagedLocalUrl(expected)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPort = getEffectiveUrlPort(current);
|
||||
const expectedPort = getEffectiveUrlPort(expected);
|
||||
if (currentPort !== expectedPort) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentPath = current.pathname.replace(/\/+$/, '') || '/';
|
||||
const expectedPath = expected.pathname.replace(/\/+$/, '') || '/';
|
||||
return currentPath !== expectedPath;
|
||||
}
|
||||
|
||||
function resolveProviderBaseUrl(
|
||||
provider: Record<string, unknown>,
|
||||
fallbackBaseUrl: string
|
||||
): string {
|
||||
const baseUrl = provider.base_url;
|
||||
if (!isValidCodexCliproxyBaseUrl(baseUrl)) {
|
||||
return fallbackBaseUrl;
|
||||
}
|
||||
|
||||
const trimmed = baseUrl.trim();
|
||||
try {
|
||||
const current = new URL(trimmed);
|
||||
const expected = new URL(fallbackBaseUrl);
|
||||
if (shouldUseManagedLocalBaseUrl(current, expected)) {
|
||||
return fallbackBaseUrl;
|
||||
}
|
||||
} catch {
|
||||
return fallbackBaseUrl;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isProviderReady(
|
||||
provider: Record<string, unknown>,
|
||||
envKey: string,
|
||||
expectedBaseUrl: string
|
||||
): boolean {
|
||||
return (
|
||||
provider.name === CODEX_CLIPROXY_PROVIDER_NAME &&
|
||||
isValidCodexCliproxyBaseUrl(provider.base_url) &&
|
||||
typeof provider.base_url === 'string' &&
|
||||
resolveProviderBaseUrl(provider, expectedBaseUrl) === provider.base_url.trim() &&
|
||||
provider.env_key === envKey &&
|
||||
provider.wire_api === 'responses' &&
|
||||
provider.requires_openai_auth === false &&
|
||||
@@ -92,17 +150,6 @@ function buildProviderConfig(baseUrl: string, envKey: string): Record<string, un
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProviderBaseUrl(
|
||||
provider: Record<string, unknown>,
|
||||
fallbackBaseUrl: string
|
||||
): string {
|
||||
const baseUrl = provider.base_url;
|
||||
if (isValidCodexCliproxyBaseUrl(baseUrl)) {
|
||||
return baseUrl.trim();
|
||||
}
|
||||
return fallbackBaseUrl;
|
||||
}
|
||||
|
||||
function appendProviderBlock(rawText: string, baseUrl: string): string {
|
||||
const prefix = rawText.trimEnd();
|
||||
const providerBlock = stringifyTomlObject({
|
||||
@@ -135,10 +182,16 @@ export async function ensureCodexCliproxyProviderConfig(
|
||||
const fileProbe = await probeTomlObjectFile(configPath, 'Codex user config', displayPath);
|
||||
|
||||
if (fileProbe.diagnostics.readError) {
|
||||
throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.readError}`);
|
||||
throw new ConfigError(
|
||||
`Cannot repair ${displayPath}: ${fileProbe.diagnostics.readError}`,
|
||||
configPath
|
||||
);
|
||||
}
|
||||
if (fileProbe.diagnostics.parseError) {
|
||||
throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.parseError}`);
|
||||
throw new ConfigError(
|
||||
`Cannot repair ${displayPath}: ${fileProbe.diagnostics.parseError}`,
|
||||
configPath
|
||||
);
|
||||
}
|
||||
|
||||
const config = fileProbe.config ?? {};
|
||||
@@ -148,7 +201,10 @@ export async function ensureCodexCliproxyProviderConfig(
|
||||
const normalizedModelAlias = normalizeTopLevelCodexModelAlias(config);
|
||||
|
||||
if (modelProvidersValue !== undefined && !providers) {
|
||||
throw new Error(`Cannot repair ${displayPath}: [model_providers] must be a table.`);
|
||||
throw new ConfigError(
|
||||
`Cannot repair ${displayPath}: [model_providers] must be a table.`,
|
||||
configPath
|
||||
);
|
||||
}
|
||||
|
||||
if (!providers || !Object.prototype.hasOwnProperty.call(providers, CODEX_CLIPROXY_PROVIDER_ID)) {
|
||||
@@ -174,13 +230,14 @@ export async function ensureCodexCliproxyProviderConfig(
|
||||
|
||||
const currentProvider = asObject(providers[CODEX_CLIPROXY_PROVIDER_ID]);
|
||||
if (!currentProvider) {
|
||||
throw new Error(
|
||||
`Cannot repair ${displayPath}: [model_providers.${CODEX_CLIPROXY_PROVIDER_ID}] must be a table.`
|
||||
throw new ConfigError(
|
||||
`Cannot repair ${displayPath}: [model_providers.${CODEX_CLIPROXY_PROVIDER_ID}] must be a table.`,
|
||||
configPath
|
||||
);
|
||||
}
|
||||
|
||||
const envKey = resolveProviderEnvKey(currentProvider);
|
||||
const providerReady = isProviderReady(currentProvider, envKey);
|
||||
const providerReady = isProviderReady(currentProvider, envKey, expectedBaseUrl);
|
||||
|
||||
if (!providerReady) {
|
||||
providers[CODEX_CLIPROXY_PROVIDER_ID] = {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
resolveCliproxyBridgeProfile,
|
||||
suggestCliproxyBridgeName,
|
||||
} from '../../../src/api/services/cliproxy-profile-bridge';
|
||||
import { invalidateConfigCache } from '../../../src/config/config-loader-facade';
|
||||
import { clearConfigCache } from '../../../src/cliproxy/config/base-config-loader';
|
||||
|
||||
describe('cliproxy-profile-bridge', () => {
|
||||
let tempHome = '';
|
||||
@@ -17,6 +19,8 @@ describe('cliproxy-profile-bridge', () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-bridge-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -25,18 +29,32 @@ describe('cliproxy-profile-bridge', () => {
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves routed profile payload for a local CLIProxy provider', () => {
|
||||
function writeBackendConfig(backend: 'original' | 'plus'): void {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
}
|
||||
|
||||
it('resolves routed profile payload for a local original-backend CLIProxy provider', () => {
|
||||
const bridge = resolveCliproxyBridgeProfile('gemini');
|
||||
|
||||
expect(bridge.name).toBe('gemini-api');
|
||||
expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/api/provider/gemini');
|
||||
expect(bridge.routePath).toBe('/api/provider/gemini');
|
||||
expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/');
|
||||
expect(bridge.routePath).toBe('/');
|
||||
expect(bridge.models.default.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -49,6 +67,7 @@ describe('cliproxy-profile-bridge', () => {
|
||||
});
|
||||
|
||||
it('detects CLIProxy-backed profile metadata and normalizes localhost loopback URLs', () => {
|
||||
writeBackendConfig('plus');
|
||||
const metadata = resolveCliproxyBridgeMetadata({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/gemini',
|
||||
|
||||
@@ -100,6 +100,19 @@ describe('provider-presets', () => {
|
||||
expect(isValidPresetId('hf')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves Tuning Engines preset metadata and aliases', () => {
|
||||
const preset = getPresetById('tuningengines');
|
||||
expect(preset?.id).toBe('tuningengines');
|
||||
expect(preset?.baseUrl).toBe('https://api.tuningengines.com/v1');
|
||||
expect(preset?.defaultProfileName).toBe('te');
|
||||
expect(preset?.defaultModel).toBe('gpt-4o');
|
||||
expect(preset?.defaultTarget).toBe('droid');
|
||||
expect(preset?.apiKeyPlaceholder).toBe('sk-te-...');
|
||||
expect(getPresetById('te')?.id).toBe('tuningengines');
|
||||
expect(getPresetById('tuning')?.id).toBe('tuningengines');
|
||||
expect(isValidPresetId('te')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses OpenRouter v1 as the OpenAI-compatible API root', () => {
|
||||
const preset = getPresetById('openrouter');
|
||||
expect(preset?.baseUrl).toBe('https://openrouter.ai/api/v1');
|
||||
|
||||
@@ -3420,3 +3420,55 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
|
||||
expect(lowerPriorityProbeStarted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GH-1588 — `--await-quit` waits for a running app to exit, then swaps + relaunches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bar install: --await-quit waits for the running app to quit (GH-1588)', () => {
|
||||
const FAKE_DOWNLOAD_URL =
|
||||
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
||||
|
||||
function baseDeps(appsDir: string, isBarRunning: () => Promise<boolean>) {
|
||||
return {
|
||||
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
||||
downloadAndExtract: async (_url: string, dest: string) => {
|
||||
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
||||
},
|
||||
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
||||
readAppBundleVersion: () => '1.0.0',
|
||||
isBarRunning,
|
||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||
getAppsDir: () => appsDir,
|
||||
};
|
||||
}
|
||||
|
||||
it('proceeds with swap + relaunch once the running app exits', async () => {
|
||||
const appsDir = path.join(tempHome, 'Applications');
|
||||
let launchCalled = false;
|
||||
// Running on the first probe (still up), gone on every probe after — this
|
||||
// models the in-app updater quitting itself right after spawning
|
||||
// `ccs bar install --launch --await-quit`.
|
||||
let probes = 0;
|
||||
const isBarRunning = async () => {
|
||||
probes += 1;
|
||||
return probes === 1;
|
||||
};
|
||||
|
||||
const { handleBarInstall } = await loadInstallSubcommand();
|
||||
|
||||
await handleBarInstall(['--launch', '--await-quit'], {
|
||||
...baseDeps(appsDir, isBarRunning),
|
||||
launchBar: async () => {
|
||||
launchCalled = true;
|
||||
},
|
||||
promptLaunch: async () => false,
|
||||
});
|
||||
|
||||
// The wait fired (app seen running first), then the install completed and
|
||||
// relaunched into the new build.
|
||||
expect(consoleOutput.join('\n')).toMatch(/Waiting for CCS Bar to quit/i);
|
||||
expect(launchCalled).toBe(true);
|
||||
expect(probes).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -115,4 +115,36 @@ describe('ProxyRequestTransformer', () => {
|
||||
expect(result.stop).toEqual(['A']);
|
||||
expect(result.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets stream_options.include_usage for streaming requests so upstreams return token usage', () => {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
const result = transformer.transform({
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
});
|
||||
|
||||
expect(result.stream).toBe(true);
|
||||
expect(result.stream_options).toEqual({ include_usage: true });
|
||||
});
|
||||
|
||||
it('omits stream_options for non-streaming requests', () => {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
const result = transformer.transform({
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
});
|
||||
|
||||
expect(result.stream).toBe(false);
|
||||
expect(result.stream_options).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits stream_options when the upstream flag is absent', () => {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
const result = transformer.transform({
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
});
|
||||
|
||||
expect(result.stream).toBe(false);
|
||||
expect(result.stream_options).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,24 +6,61 @@ import {
|
||||
buildCodexCliproxyProviderBaseUrl,
|
||||
ensureCodexCliproxyProviderConfig,
|
||||
} from '../../../src/targets/codex-cliproxy-provider-config';
|
||||
import { invalidateConfigCache } from '../../../src/config/config-loader-facade';
|
||||
import { clearConfigCache } from '../../../src/cliproxy/config/base-config-loader';
|
||||
|
||||
describe('codex cliproxy provider config repair', () => {
|
||||
let tempHome: string;
|
||||
let codexHome: string;
|
||||
let configPath: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-provider-config-'));
|
||||
codexHome = path.join(tempHome, '.codex');
|
||||
configPath = path.join(codexHome, 'config.toml');
|
||||
env = { CODEX_HOME: codexHome } as NodeJS.ProcessEnv;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeBackendConfig(backend: 'original' | 'plus'): void {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
}
|
||||
|
||||
it('uses the original backend root URL by default', () => {
|
||||
expect(buildCodexCliproxyProviderBaseUrl(8317)).toBe('http://127.0.0.1:8317');
|
||||
});
|
||||
|
||||
it('uses the plus backend scoped Codex URL when configured', () => {
|
||||
writeBackendConfig('plus');
|
||||
expect(buildCodexCliproxyProviderBaseUrl(8317)).toBe(
|
||||
'http://127.0.0.1:8317/api/provider/codex'
|
||||
);
|
||||
});
|
||||
|
||||
it('creates the cliproxy model provider when config.toml is missing', async () => {
|
||||
const result = await ensureCodexCliproxyProviderConfig(8317, env);
|
||||
|
||||
@@ -32,6 +69,7 @@ describe('codex cliproxy provider config repair', () => {
|
||||
const rawText = fs.readFileSync(configPath, 'utf8');
|
||||
expect(rawText).toContain('[model_providers.cliproxy]');
|
||||
expect(rawText).toContain('name = "CLIProxy Codex"');
|
||||
expect(rawText).toContain('base_url = "http://127.0.0.1:8317"');
|
||||
expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"');
|
||||
expect(rawText).not.toContain('model_provider = "cliproxy"');
|
||||
});
|
||||
@@ -78,7 +116,7 @@ wire_api = "responses"
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.envKey).toBe('CLIPROXY_API_KEY');
|
||||
const rawText = fs.readFileSync(configPath, 'utf8');
|
||||
expect(rawText).toContain('base_url = "http://localhost:8317/api/provider/codex"');
|
||||
expect(rawText).toContain('base_url = "http://127.0.0.1:9321"');
|
||||
expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"');
|
||||
expect(rawText).toContain('requires_openai_auth = false');
|
||||
expect(rawText).toContain('supports_websockets = false');
|
||||
@@ -142,7 +180,29 @@ supports_websockets = false
|
||||
expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText);
|
||||
});
|
||||
|
||||
it('leaves a ready localhost provider unchanged', async () => {
|
||||
it('repairs a stale ready localhost provider to the original backend root URL', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const rawText = `[model_providers.cliproxy]
|
||||
name = "CLIProxy Codex"
|
||||
base_url = "http://localhost:8317/api/provider/codex"
|
||||
env_key = "CLIPROXY_API_KEY"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
supports_websockets = false
|
||||
`;
|
||||
fs.writeFileSync(configPath, rawText, 'utf8');
|
||||
|
||||
const result = await ensureCodexCliproxyProviderConfig(8317, env);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.envKey).toBe('CLIPROXY_API_KEY');
|
||||
const repairedText = fs.readFileSync(configPath, 'utf8');
|
||||
expect(repairedText).toContain('base_url = "http://127.0.0.1:8317"');
|
||||
expect(repairedText).not.toContain('/api/provider/codex');
|
||||
});
|
||||
|
||||
it('leaves a ready plus-backend localhost provider unchanged', async () => {
|
||||
writeBackendConfig('plus');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const rawText = `[model_providers.cliproxy]
|
||||
name = "CLIProxy Codex"
|
||||
@@ -188,7 +248,7 @@ supports_websockets = false
|
||||
|
||||
[model_providers.cliproxy]
|
||||
name = "CLIProxy Codex"
|
||||
base_url = "http://localhost:8317/api/provider/codex"
|
||||
base_url = "http://127.0.0.1:8317"
|
||||
env_key = "CLIPROXY_API_KEY"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
@@ -239,7 +299,7 @@ supports_websockets = false
|
||||
|
||||
[model_providers.cliproxy]
|
||||
name = "CLIProxy Codex"
|
||||
base_url = "http://localhost:8317/api/provider/codex"
|
||||
base_url = "http://127.0.0.1:8317"
|
||||
env_key = "CLIPROXY_API_KEY"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { UnifiedConfig } from '../../../../src/config/unified-config-types';
|
||||
import { runBrowserSetup, type BrowserSetupDeps } from '../../../../src/utils/browser/browser-setup';
|
||||
import {
|
||||
runBrowserSetup,
|
||||
type BrowserSetupDeps,
|
||||
} from '../../../../src/utils/browser/browser-setup';
|
||||
|
||||
function createUnifiedConfig(userDataDir: string): UnifiedConfig {
|
||||
return {
|
||||
@@ -96,8 +99,36 @@ function createUnifiedConfig(userDataDir: string): UnifiedConfig {
|
||||
|
||||
describe('browser setup', () => {
|
||||
let tempDir = '';
|
||||
let originalBrowserUserDataDir: string | undefined;
|
||||
let originalBrowserProfileDir: string | undefined;
|
||||
let originalBrowserDevtoolsPort: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||
originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR;
|
||||
originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
delete process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||
delete process.env.CCS_BROWSER_PROFILE_DIR;
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBrowserUserDataDir !== undefined) {
|
||||
process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir;
|
||||
} else {
|
||||
delete process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||
}
|
||||
if (originalBrowserProfileDir !== undefined) {
|
||||
process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir;
|
||||
} else {
|
||||
delete process.env.CCS_BROWSER_PROFILE_DIR;
|
||||
}
|
||||
if (originalBrowserDevtoolsPort !== undefined) {
|
||||
process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort;
|
||||
} else {
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
}
|
||||
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
|
||||
@@ -5,6 +5,8 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
import profileRoutes from '../../../src/web-server/routes/profile-routes';
|
||||
import { invalidateConfigCache } from '../../../src/config/config-loader-facade';
|
||||
import { clearConfigCache } from '../../../src/cliproxy/config/base-config-loader';
|
||||
|
||||
describe('profile-routes cliproxy bridge', () => {
|
||||
let server: Server;
|
||||
@@ -42,6 +44,8 @@ describe('profile-routes cliproxy bridge', () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-cliproxy-bridge-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -50,6 +54,8 @@ describe('profile-routes cliproxy bridge', () => {
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
|
||||
Reference in new issue
Block a user