diff --git a/README.md b/README.md
index d0e0474de..45eff5249 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# CodexBar 🎚️ - May your tokens never run out.
-Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, Perplexity, and Abacus AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
+Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Manus, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, Perplexity, and Abacus AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
@@ -39,6 +39,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
- [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing.
- [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API.
- [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows.
+- [Manus](docs/manus.md) — Browser `session_id` auth for credit balance, monthly credits, and daily refresh tracking.
- [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit.
- [Kimi K2](docs/kimi-k2.md) — API key for credit-based usage totals.
- [Kiro](docs/kiro.md) — CLI-based usage via `kiro-cli /usage` command; monthly credits + bonus credits.
@@ -72,7 +73,7 @@ The menu bar icon is a tiny two-bar meter:
Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it reads a small set of known locations (browser cookies/local storage, local JSONL logs) when the related features are enabled. See the discussion and audit notes in [issue #12](https://github.com/steipete/CodexBar/issues/12).
## macOS permissions (why they’re needed)
-- **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers (Codex web, Claude web, Cursor, Droid/Factory). If you don’t grant it, use Chrome/Firefox cookies or CLI-only sources instead.
+- **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers (Codex web, Claude web, Cursor, Droid/Factory, Manus, Abacus AI). If you don’t grant it, use Chrome/Firefox cookies or CLI-only sources instead.
- **Keychain access (prompted by macOS)**:
- Chrome cookie import needs the “Chrome Safe Storage” key to decrypt cookies.
- Claude OAuth credentials (written by the Claude CLI) are read from Keychain when present.
diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 4d9c05127..f9b136735 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -1054,6 +1054,13 @@ extension UsageMenuCardView.Model {
{
primaryDetailText = detail
}
+ if input.provider == .manus,
+ let detail = primary.resetDescription,
+ !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ primaryDetailText = detail
+ primaryResetText = nil
+ }
if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil {
primaryResetText = nil
}
@@ -1134,6 +1141,12 @@ extension UsageMenuCardView.Model {
{
weeklyDetailText = detail
}
+ if input.provider == .manus,
+ let detail = weekly.resetDescription,
+ !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ weeklyDetailText = detail
+ }
// Perplexity bonus credits don't reset; show balance without "Resets" prefix.
if input.provider == .perplexity,
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -1376,6 +1389,9 @@ extension UsageMenuCardView.Model {
provider: UsageProvider,
cost: ProviderCostSnapshot?) -> ProviderCostSection?
{
+ if provider == .manus {
+ return nil
+ }
guard let cost else { return nil }
guard cost.limit > 0 else { return nil }
diff --git a/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift b/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift
new file mode 100644
index 000000000..873ffca56
--- /dev/null
+++ b/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift
@@ -0,0 +1,109 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct ManusProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .manus
+ let supportsLoginFlow: Bool = true
+
+ @MainActor
+ func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
+ ProviderPresentation { _ in "web" }
+ }
+
+ @MainActor
+ func runLoginFlow(context _: ProviderLoginContext) async -> Bool {
+ if let url = URL(string: "https://manus.im") {
+ NSWorkspace.shared.open(url)
+ }
+ return false
+ }
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.manusCookieSource
+ _ = settings.manusManualCookieHeader
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .manus(context.settings.manusSettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool {
+ guard support.requiresManualCookieSource else { return true }
+ if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true }
+ return context.settings.manusCookieSource == .manual
+ }
+
+ @MainActor
+ func applyTokenAccountCookieSource(settings: SettingsStore) {
+ if settings.manusCookieSource != .manual {
+ settings.manusCookieSource = .manual
+ }
+ }
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let cookieBinding = Binding(
+ get: { context.settings.manusCookieSource.rawValue },
+ set: { raw in
+ context.settings.manusCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let options = ProviderCookieSourceUI.options(
+ allowsOff: true,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+
+ let subtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.manusCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Automatically imports browser session cookies.",
+ manual: "Paste the session_id value or a full Cookie header.",
+ off: "Manus cookies are disabled.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "manus-cookie-source",
+ title: "Cookie source",
+ subtitle: "Automatically imports browser session cookies.",
+ dynamicSubtitle: subtitle,
+ binding: cookieBinding,
+ options: options,
+ isVisible: nil,
+ onChange: nil),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "manus-cookie",
+ title: "",
+ subtitle: "",
+ kind: .secure,
+ placeholder: "session_id=...\n\nor paste just the session_id value",
+ binding: context.stringBinding(\.manusManualCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "manus-open-dashboard",
+ title: "Open Manus",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://manus.im") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.manusCookieSource == .manual },
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift b/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift
new file mode 100644
index 000000000..1b4573a1b
--- /dev/null
+++ b/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift
@@ -0,0 +1,60 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var manusManualCookieHeader: String {
+ get { self.configSnapshot.providerConfig(for: .manus)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .manus) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .manus, field: "cookieHeader", value: newValue)
+ }
+ }
+
+ var manusCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .manus, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .manus) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .manus, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+}
+
+extension SettingsStore {
+ func manusSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.ManusProviderSettings {
+ ProviderSettingsSnapshot.ManusProviderSettings(
+ cookieSource: self.manusSnapshotCookieSource(tokenOverride: tokenOverride),
+ manualCookieHeader: self.manusSnapshotCookieHeader(tokenOverride: tokenOverride))
+ }
+
+ private func manusSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String {
+ let fallback = self.manusManualCookieHeader
+ guard let support = TokenAccountSupportCatalog.support(for: .manus),
+ case .cookieHeader = support.injection
+ else {
+ return fallback
+ }
+ guard let account = ProviderTokenAccountSelection.selectedAccount(
+ provider: .manus,
+ settings: self,
+ override: tokenOverride)
+ else {
+ return fallback
+ }
+ return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support)
+ }
+
+ private func manusSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource {
+ let fallback = self.manusCookieSource
+ guard let support = TokenAccountSupportCatalog.support(for: .manus),
+ support.requiresManualCookieSource
+ else {
+ return fallback
+ }
+ if self.tokenAccounts(for: .manus).isEmpty { return fallback }
+ return .manual
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 6dd8c45c4..42750c917 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -25,6 +25,7 @@ enum ProviderImplementationRegistry {
case .copilot: CopilotProviderImplementation()
case .zai: ZaiProviderImplementation()
case .minimax: MiniMaxProviderImplementation()
+ case .manus: ManusProviderImplementation()
case .kimi: KimiProviderImplementation()
case .kilo: KiloProviderImplementation()
case .kiro: KiroProviderImplementation()
diff --git a/Sources/CodexBar/Resources/ProviderIcon-manus.svg b/Sources/CodexBar/Resources/ProviderIcon-manus.svg
new file mode 100644
index 000000000..fcfd81daf
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-manus.svg
@@ -0,0 +1,6 @@
+
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index d2af04b21..d080e17b4 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -797,6 +797,7 @@ extension UsageStore {
.alibaba: "Alibaba Coding Plan debug log not yet implemented",
.factory: "Droid debug log not yet implemented",
.copilot: "Copilot debug log not yet implemented",
+ .manus: "Manus debug log not yet implemented",
.vertexai: "Vertex AI debug log not yet implemented",
.kilo: "Kilo debug log not yet implemented",
.kiro: "Kiro debug log not yet implemented",
@@ -875,8 +876,8 @@ extension UsageStore {
let hasAny = resolution != nil
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
- case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
- .kimik2, .jetbrains, .perplexity, .abacus:
+ case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .manus, .vertexai, .kilo, .kiro,
+ .kimi, .kimik2, .jetbrains, .perplexity, .abacus:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index e43073e82..057697997 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -139,6 +139,13 @@ struct TokenAccountCLIContext {
cookieSource: cookieSource,
manualCookieHeader: cookieHeader,
apiRegion: self.resolveMiniMaxRegion(config)))
+ case .manus:
+ let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
+ let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
+ return self.makeSnapshot(
+ manus: ProviderSettingsSnapshot.ManusProviderSettings(
+ cookieSource: cookieSource,
+ manualCookieHeader: cookieHeader))
case .augment:
let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
@@ -207,6 +214,7 @@ struct TokenAccountCLIContext {
alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? = nil,
factory: ProviderSettingsSnapshot.FactoryProviderSettings? = nil,
minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? = nil,
+ manus: ProviderSettingsSnapshot.ManusProviderSettings? = nil,
zai: ProviderSettingsSnapshot.ZaiProviderSettings? = nil,
kilo: ProviderSettingsSnapshot.KiloProviderSettings? = nil,
kimi: ProviderSettingsSnapshot.KimiProviderSettings? = nil,
@@ -226,6 +234,7 @@ struct TokenAccountCLIContext {
alibaba: alibaba,
factory: factory,
minimax: minimax,
+ manus: manus,
zai: zai,
kilo: kilo,
kimi: kimi,
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index 90a7175d2..d091e5f32 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -34,6 +34,9 @@ public enum LogCategories {
public static let launchAtLogin = "launch-at-login"
public static let login = "login"
public static let logging = "logging"
+ public static let manusAPI = "manus-api"
+ public static let manusCookie = "manus-cookie"
+ public static let manusWeb = "manus-web"
public static let minimaxAPITokenStore = "minimax-api-token-store"
public static let minimaxCookie = "minimax-cookie"
public static let minimaxCookieStore = "minimax-cookie-store"
diff --git a/Sources/CodexBarCore/Providers/Manus/ManusCookieHeader.swift b/Sources/CodexBarCore/Providers/Manus/ManusCookieHeader.swift
new file mode 100644
index 000000000..e4c7fa878
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Manus/ManusCookieHeader.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+public enum ManusCookieHeader {
+ public static let sessionCookieName = "session_id"
+
+ public static func resolveToken(context: ProviderFetchContext) -> String? {
+ guard let settings = context.settings?.manus, settings.cookieSource == .manual else { return nil }
+ return self.token(from: settings.manualCookieHeader)
+ }
+
+ public static func token(from raw: String?) -> String? {
+ guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
+ return nil
+ }
+
+ if !raw.contains("="), !raw.contains(";") {
+ return raw
+ }
+
+ let pairs = CookieHeaderNormalizer.pairs(from: raw)
+ for pair in pairs where pair.name.caseInsensitiveCompare(self.sessionCookieName) == .orderedSame {
+ let token = pair.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !token.isEmpty {
+ return token
+ }
+ }
+ return nil
+ }
+
+ public static func sessionToken(from cookies: [HTTPCookie]) -> String? {
+ for cookie in cookies where cookie.name.caseInsensitiveCompare(self.sessionCookieName) == .orderedSame {
+ let token = cookie.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !token.isEmpty {
+ return token
+ }
+ }
+ return nil
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Manus/ManusCookieImporter.swift b/Sources/CodexBarCore/Providers/Manus/ManusCookieImporter.swift
new file mode 100644
index 000000000..e8affb76e
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Manus/ManusCookieImporter.swift
@@ -0,0 +1,188 @@
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+
+public enum ManusCookieImporter {
+ private static let log = CodexBarLog.logger(LogCategories.manusCookie)
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = ["manus.im", "www.manus.im"]
+ private static let cookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.manus]?.browserCookieOrder ?? Browser.defaultImportOrder
+ nonisolated(unsafe) static var importSessionOverrideForTesting:
+ ((BrowserDetection, ((String) -> Void)?) throws -> SessionInfo)?
+ nonisolated(unsafe) static var importSessionsOverrideForTesting:
+ ((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])?
+
+ public struct SessionInfo: Sendable {
+ public let cookies: [HTTPCookie]
+ public let sourceLabel: String
+
+ public init(cookies: [HTTPCookie], sourceLabel: String) {
+ self.cookies = cookies
+ self.sourceLabel = sourceLabel
+ }
+
+ public var sessionToken: String? {
+ ManusCookieHeader.sessionToken(from: self.cookies)
+ }
+ }
+
+ public static func importSessions(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ if let override = self.importSessionsOverrideForTesting {
+ return try override(browserDetection, logger)
+ }
+ if let override = self.importSessionOverrideForTesting {
+ return try [override(browserDetection, logger)]
+ }
+
+ var sessions: [SessionInfo] = []
+ let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
+ for browserSource in candidates {
+ do {
+ let perSource = try self.importSessions(from: browserSource, logger: logger)
+ sessions.append(contentsOf: perSource)
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ self.emit(
+ "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)",
+ logger: logger)
+ }
+ }
+
+ guard !sessions.isEmpty else {
+ throw ManusCookieImportError.noCookies
+ }
+ return sessions
+ }
+
+ public static func importSessions(
+ from browserSource: Browser,
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let log: (String) -> Void = { message in self.emit(message, logger: logger) }
+ let sources = try Self.cookieClient.codexBarRecords(
+ matching: query,
+ in: browserSource,
+ logger: log)
+
+ var sessions: [SessionInfo] = []
+ let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id })
+ let sortedGroups = grouped.values.sorted { lhs, rhs in
+ self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs)
+ }
+
+ for group in sortedGroups where !group.isEmpty {
+ let label = self.mergedLabel(for: group)
+ let mergedRecords = self.mergeRecords(group)
+ guard !mergedRecords.isEmpty else { continue }
+ let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin)
+ guard !httpCookies.isEmpty else { continue }
+
+ let session = SessionInfo(cookies: httpCookies, sourceLabel: label)
+ guard let token = session.sessionToken else {
+ continue
+ }
+
+ log("Found \(ManusCookieHeader.sessionCookieName) cookie in \(label)")
+ if !token.isEmpty {
+ sessions.append(session)
+ }
+ }
+
+ return sessions
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger)
+ guard let first = sessions.first else {
+ throw ManusCookieImportError.noCookies
+ }
+ return first
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ do {
+ _ = try self.importSession(browserDetection: browserDetection, logger: logger)
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ private static func emit(_ message: String, logger: ((String) -> Void)?) {
+ logger?("[manus-cookie] \(message)")
+ self.log.debug(message)
+ }
+
+ private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String {
+ guard let base = sources.map(\.label).min() else { return "Unknown" }
+ if base.hasSuffix(" (Network)") {
+ return String(base.dropLast(" (Network)".count))
+ }
+ return base
+ }
+
+ private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] {
+ let sortedSources = sources.sorted { lhs, rhs in
+ self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind)
+ }
+ var mergedByKey: [String: BrowserCookieRecord] = [:]
+ for source in sortedSources {
+ for record in source.records {
+ let key = self.recordKey(record)
+ if let existing = mergedByKey[key] {
+ if self.shouldReplace(existing: existing, candidate: record) {
+ mergedByKey[key] = record
+ }
+ } else {
+ mergedByKey[key] = record
+ }
+ }
+ }
+ return Array(mergedByKey.values)
+ }
+
+ private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int {
+ switch kind {
+ case .network: 0
+ case .primary: 1
+ case .safari: 2
+ }
+ }
+
+ private static func recordKey(_ record: BrowserCookieRecord) -> String {
+ "\(record.name)|\(record.domain)|\(record.path)"
+ }
+
+ private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool {
+ switch (existing.expires, candidate.expires) {
+ case let (lhs?, rhs?): rhs > lhs
+ case (nil, .some): true
+ case (.some, nil): false
+ case (nil, nil): false
+ }
+ }
+}
+
+enum ManusCookieImportError: LocalizedError {
+ case noCookies
+
+ var errorDescription: String? {
+ switch self {
+ case .noCookies:
+ "No Manus session cookies found in browsers. Please log into manus.im."
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift
new file mode 100644
index 000000000..838c6e5eb
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift
@@ -0,0 +1,182 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum ManusProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .manus,
+ metadata: ProviderMetadata(
+ id: .manus,
+ displayName: "Manus",
+ sessionLabel: "Monthly credits",
+ weeklyLabel: "Daily refresh",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Manus usage",
+ cliName: "manus",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder,
+ dashboardURL: "https://manus.im",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .manus,
+ iconResourceName: "ProviderIcon-manus",
+ color: ProviderColor(red: 52 / 255, green: 50 / 255, blue: 45 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Manus cost summary is not supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ManusWebFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "manus",
+ aliases: [],
+ versionDetector: nil))
+ }
+}
+
+struct ManusWebFetchStrategy: ProviderFetchStrategy {
+ private enum SessionTokenSource {
+ case manual
+ case cache
+ case browser
+ case environment
+
+ var shouldCacheAfterFetch: Bool {
+ self == .browser
+ }
+ }
+
+ private struct ResolvedSessionToken {
+ let value: String
+ let source: SessionTokenSource
+ }
+
+ let id: String = "manus.web"
+ let kind: ProviderFetchKind = .web
+ private static let log = CodexBarLog.logger(LogCategories.manusWeb)
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ guard context.settings?.manus?.cookieSource != .off else { return false }
+ if context.settings?.manus?.cookieSource == .manual { return true }
+
+ if let cached = CookieHeaderCache.load(provider: .manus),
+ ManusCookieHeader.token(from: cached.cookieHeader) != nil
+ {
+ return true
+ }
+
+ #if os(macOS)
+ if ManusCookieImporter.hasSession(browserDetection: context.browserDetection) {
+ return true
+ }
+ #endif
+
+ return ManusSettingsReader.sessionToken(environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let resolvedTokens = try self.resolveSessionTokens(context: context)
+ guard !resolvedTokens.isEmpty else {
+ throw ManusAPIError.missingToken
+ }
+
+ var sawInvalidToken = false
+ for resolved in resolvedTokens {
+ do {
+ let response = try await ManusUsageFetcher.fetchCredits(sessionToken: resolved.value)
+ self.cacheTokenIfNeeded(resolved, sourceLabel: "web")
+ return self.makeResult(
+ usage: response.toUsageSnapshot(),
+ sourceLabel: "web")
+ } catch ManusAPIError.invalidToken {
+ sawInvalidToken = true
+ if resolved.source == .cache {
+ CookieHeaderCache.clear(provider: .manus)
+ }
+ continue
+ }
+ }
+
+ if sawInvalidToken {
+ throw ManusAPIError.invalidToken
+ }
+ throw ManusAPIError.missingToken
+ }
+
+ func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool {
+ if case ManusAPIError.missingToken = error { return false }
+ if case ManusAPIError.invalidCookie = error { return false }
+ if case ManusAPIError.invalidToken = error { return false }
+ return true
+ }
+
+ private func resolveSessionTokens(context: ProviderFetchContext) throws -> [ResolvedSessionToken] {
+ guard context.settings?.manus?.cookieSource != .off else { return [] }
+
+ if context.settings?.manus?.cookieSource == .manual {
+ guard let token = ManusCookieHeader.resolveToken(context: context) else {
+ throw ManusAPIError.invalidCookie
+ }
+ return [ResolvedSessionToken(value: token, source: .manual)]
+ }
+
+ var tokens: [ResolvedSessionToken] = []
+
+ if let cached = CookieHeaderCache.load(provider: .manus),
+ let token = ManusCookieHeader.token(from: cached.cookieHeader)
+ {
+ tokens.append(ResolvedSessionToken(value: token, source: .cache))
+ }
+
+ tokens.append(contentsOf: self.resolveBrowserOrEnvironmentTokens(context: context))
+ return self.deduplicated(tokens)
+ }
+
+ private func resolveBrowserOrEnvironmentTokens(context: ProviderFetchContext) -> [ResolvedSessionToken] {
+ guard context.settings?.manus?.cookieSource != .off else { return [] }
+ var tokens: [ResolvedSessionToken] = []
+
+ #if os(macOS)
+ do {
+ let sessions = try ManusCookieImporter.importSessions(browserDetection: context.browserDetection)
+ tokens.append(contentsOf: sessions.compactMap { session in
+ guard let token = session.sessionToken else { return nil }
+ return ResolvedSessionToken(value: token, source: .browser)
+ })
+ } catch {
+ Self.log.debug("No Manus browser session available: \(error.localizedDescription)")
+ }
+ #endif
+
+ if let token = ManusSettingsReader.sessionToken(environment: context.env) {
+ tokens.append(ResolvedSessionToken(value: token, source: .environment))
+ }
+ return self.deduplicated(tokens)
+ }
+
+ private func deduplicated(_ tokens: [ResolvedSessionToken]) -> [ResolvedSessionToken] {
+ var seen: Set = []
+ var deduplicated: [ResolvedSessionToken] = []
+ for token in tokens where !token.value.isEmpty {
+ if seen.insert(token.value).inserted {
+ deduplicated.append(token)
+ }
+ }
+ return deduplicated
+ }
+
+ private func cacheTokenIfNeeded(_ token: ResolvedSessionToken, sourceLabel: String) {
+ guard token.source.shouldCacheAfterFetch else { return }
+ CookieHeaderCache.store(
+ provider: .manus,
+ cookieHeader: "\(ManusCookieHeader.sessionCookieName)=\(token.value)",
+ sourceLabel: sourceLabel)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Manus/ManusSettingsReader.swift b/Sources/CodexBarCore/Providers/Manus/ManusSettingsReader.swift
new file mode 100644
index 000000000..fa5874c12
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Manus/ManusSettingsReader.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+public enum ManusSettingsReader {
+ public static func sessionToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ let rawToken = environment["MANUS_SESSION_TOKEN"]
+ ?? environment["manus_session_token"]
+ ?? environment["MANUS_SESSION_ID"]
+ ?? environment["manus_session_id"]
+ if let token = ManusCookieHeader.token(from: self.cleaned(rawToken)) {
+ return token
+ }
+
+ let rawCookie = environment["MANUS_COOKIE"] ?? environment["manus_cookie"]
+ return ManusCookieHeader.token(from: self.cleaned(rawCookie))
+ }
+
+ private static func cleaned(_ raw: String?) -> String? {
+ guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+
+ value = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift
new file mode 100644
index 000000000..9de2fa8cd
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift
@@ -0,0 +1,298 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct ManusCreditsResponse: Decodable, Sendable {
+ public let totalCredits: Double
+ public let freeCredits: Double
+ public let periodicCredits: Double
+ public let addonCredits: Double
+ public let refreshCredits: Double
+ public let maxRefreshCredits: Double
+ public let proMonthlyCredits: Double
+ public let eventCredits: Double
+ public let nextRefreshTime: Date?
+ public let refreshInterval: String?
+
+ public init(
+ totalCredits: Double,
+ freeCredits: Double,
+ periodicCredits: Double,
+ addonCredits: Double,
+ refreshCredits: Double,
+ maxRefreshCredits: Double,
+ proMonthlyCredits: Double,
+ eventCredits: Double,
+ nextRefreshTime: Date? = nil,
+ refreshInterval: String? = nil)
+ {
+ self.totalCredits = totalCredits
+ self.freeCredits = freeCredits
+ self.periodicCredits = periodicCredits
+ self.addonCredits = addonCredits
+ self.refreshCredits = refreshCredits
+ self.maxRefreshCredits = maxRefreshCredits
+ self.proMonthlyCredits = proMonthlyCredits
+ self.eventCredits = eventCredits
+ self.nextRefreshTime = nextRefreshTime
+ self.refreshInterval = refreshInterval
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case totalCredits
+ case freeCredits
+ case periodicCredits
+ case addonCredits
+ case refreshCredits
+ case maxRefreshCredits
+ case proMonthlyCredits
+ case eventCredits
+ case nextRefreshTime
+ case refreshInterval
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.totalCredits = container.decodeLossyDoubleIfPresent(forKey: .totalCredits) ?? 0
+ self.freeCredits = container.decodeLossyDoubleIfPresent(forKey: .freeCredits) ?? 0
+ self.periodicCredits = container.decodeLossyDoubleIfPresent(forKey: .periodicCredits) ?? 0
+ self.addonCredits = container.decodeLossyDoubleIfPresent(forKey: .addonCredits) ?? 0
+ self.refreshCredits = container.decodeLossyDoubleIfPresent(forKey: .refreshCredits) ?? 0
+ self.maxRefreshCredits = container.decodeLossyDoubleIfPresent(forKey: .maxRefreshCredits) ?? 0
+ self.proMonthlyCredits = container.decodeLossyDoubleIfPresent(forKey: .proMonthlyCredits) ?? 0
+ self.eventCredits = container.decodeLossyDoubleIfPresent(forKey: .eventCredits) ?? 0
+ self.nextRefreshTime = container.decodeIfPresentFlexibleDate(forKey: .nextRefreshTime)
+ self.refreshInterval = try? container.decodeIfPresent(String.self, forKey: .refreshInterval)
+ }
+}
+
+public enum ManusUsageFetcher {
+ private static let log = CodexBarLog.logger(LogCategories.manusAPI)
+ private static let creditsURL =
+ URL(string: "https://api.manus.im/user.v1.UserService/GetAvailableCredits")!
+ @TaskLocal static var fetchCreditsOverride:
+ (@Sendable (String, Date) async throws -> ManusCreditsResponse)?
+
+ public static func fetchCredits(
+ sessionToken: String,
+ now: Date = Date()) async throws -> ManusCreditsResponse
+ {
+ if let override = self.fetchCreditsOverride {
+ return try await override(sessionToken, now)
+ }
+
+ guard !sessionToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw ManusAPIError.missingToken
+ }
+
+ var request = URLRequest(url: self.creditsURL)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 15
+ request.httpBody = Data("{}".utf8)
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("Bearer \(sessionToken)", forHTTPHeaderField: "Authorization")
+ request.setValue("https://manus.im", forHTTPHeaderField: "Origin")
+ request.setValue("https://manus.im/", forHTTPHeaderField: "Referer")
+ request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version")
+ let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
+ request.setValue(
+ userAgent,
+ forHTTPHeaderField: "User-Agent")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw ManusAPIError.networkError("Invalid response")
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let body = String(data: data, encoding: .utf8) ?? ""
+ let truncated = body.count > 200 ? String(body.prefix(200)) + "…" : body
+ Self.log.error("Manus API returned \(httpResponse.statusCode): \(truncated)")
+ if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
+ throw ManusAPIError.invalidToken
+ }
+ throw ManusAPIError.apiError("HTTP \(httpResponse.statusCode)")
+ }
+
+ do {
+ return try self.parseResponse(data)
+ } catch let error as ManusAPIError {
+ throw error
+ } catch {
+ let preview = String(data: data.prefix(500), encoding: .utf8) ?? ""
+ Self.log.error("Manus parse failed: \(error) — response: \(preview)")
+ throw ManusAPIError.parseFailed(error.localizedDescription)
+ }
+ }
+
+ public static func parseResponse(_ data: Data) throws -> ManusCreditsResponse {
+ let decoder = JSONDecoder()
+
+ // Try envelope first — the direct decoder defaults missing fields to 0,
+ // so it would "succeed" on wrapped payloads and silently return zero credits.
+ if let envelope = try? decoder.decode(ManusCreditsEnvelope.self, from: data),
+ let response = envelope.data ?? envelope.result ?? envelope.response ?? envelope.availableCredits
+ {
+ return response
+ }
+
+ let response = try decoder.decode(ManusCreditsResponse.self, from: data)
+ // The custom decoder defaults every numeric field to 0, so an unrelated JSON
+ // object (e.g. an error payload) would otherwise surface as a bogus zero-credit
+ // snapshot. Require at least one known credits key in the raw payload.
+ guard Self.payloadContainsCreditsField(data: data) else {
+ throw ManusAPIError.parseFailed("response missing expected credits fields")
+ }
+ return response
+ }
+
+ private static let expectedCreditsKeys: Set = [
+ "totalCredits", "freeCredits", "periodicCredits", "addonCredits",
+ "refreshCredits", "maxRefreshCredits", "proMonthlyCredits", "eventCredits",
+ ]
+
+ private static func payloadContainsCreditsField(data: Data) -> Bool {
+ guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ return false
+ }
+ return !Self.expectedCreditsKeys.isDisjoint(with: object.keys)
+ }
+}
+
+extension ManusCreditsResponse {
+ public func toUsageSnapshot(now: Date = Date()) -> UsageSnapshot {
+ let primary: RateWindow? = if self.proMonthlyCredits > 0 {
+ RateWindow(
+ usedPercent: min(
+ 100,
+ max(0, (self.proMonthlyCredits - self.periodicCredits) / self.proMonthlyCredits * 100)),
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: Self.monthlyDetail(totalCredits: self.totalCredits, freeCredits: self.freeCredits))
+ } else {
+ nil
+ }
+
+ let secondary: RateWindow? = if self.maxRefreshCredits > 0 {
+ RateWindow(
+ usedPercent: min(
+ 100,
+ max(0, (self.maxRefreshCredits - self.refreshCredits) / self.maxRefreshCredits * 100)),
+ windowMinutes: nil,
+ resetsAt: self.nextRefreshTime,
+ resetDescription: Self.refreshDetail(
+ refreshCredits: self.refreshCredits,
+ maxRefreshCredits: self.maxRefreshCredits,
+ refreshInterval: self.refreshInterval))
+ } else {
+ nil
+ }
+
+ let balance = Self.creditCountString(self.totalCredits)
+ let identity = ProviderIdentitySnapshot(
+ providerID: .manus,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: "Balance: \(balance) credits")
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: now,
+ identity: identity)
+ }
+
+ private static func creditCountString(_ value: Double) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.usesGroupingSeparator = true
+ formatter.maximumFractionDigits = 0
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ return formatter.string(from: NSNumber(value: value.rounded())) ?? String(Int(value.rounded()))
+ }
+
+ private static func monthlyDetail(totalCredits: Double, freeCredits: Double) -> String? {
+ let total = self.creditCountString(totalCredits)
+ let free = self.creditCountString(freeCredits)
+ return "Total \(total) • Free \(free)"
+ }
+
+ private static func refreshDetail(
+ refreshCredits: Double,
+ maxRefreshCredits: Double,
+ refreshInterval: String?) -> String?
+ {
+ let refresh = self.creditCountString(refreshCredits)
+ let maxRefresh = self.creditCountString(maxRefreshCredits)
+ if let refreshInterval, !refreshInterval.isEmpty {
+ return "\(refreshInterval.capitalized): \(refresh) / \(maxRefresh)"
+ }
+ return "\(refresh) / \(maxRefresh)"
+ }
+}
+
+public enum ManusAPIError: LocalizedError, Equatable, Sendable {
+ case missingToken
+ case invalidCookie
+ case invalidToken
+ case networkError(String)
+ case apiError(String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingToken:
+ "No Manus session token provided."
+ case .invalidCookie:
+ "Manus session cookie is invalid."
+ case .invalidToken:
+ "Invalid Manus session token."
+ case let .networkError(message):
+ "Manus network error: \(message)"
+ case let .apiError(message):
+ "Manus API error: \(message)"
+ case let .parseFailed(message):
+ "Failed to parse Manus response: \(message)"
+ }
+ }
+}
+
+private struct ManusCreditsEnvelope: Decodable {
+ let data: ManusCreditsResponse?
+ let result: ManusCreditsResponse?
+ let response: ManusCreditsResponse?
+ let availableCredits: ManusCreditsResponse?
+}
+
+extension KeyedDecodingContainer where K: CodingKey {
+ fileprivate func decodeLossyDoubleIfPresent(forKey key: K) -> Double? {
+ if let value = try? self.decodeIfPresent(Double.self, forKey: key) {
+ return value
+ }
+ if let intValue = try? self.decodeIfPresent(Int.self, forKey: key) {
+ return Double(intValue)
+ }
+ if let stringValue = try? self.decodeIfPresent(String.self, forKey: key) {
+ return Double(stringValue.trimmingCharacters(in: .whitespacesAndNewlines))
+ }
+ return nil
+ }
+
+ fileprivate func decodeIfPresentFlexibleDate(forKey key: K) -> Date? {
+ if let value = try? self.decodeIfPresent(Date.self, forKey: key) {
+ return value
+ }
+ guard let stringValue = try? self.decodeIfPresent(String.self, forKey: key),
+ !stringValue.isEmpty
+ else {
+ return nil
+ }
+ return ISO8601DateFormatter().date(from: stringValue)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 6fb994efc..db30ab09d 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -65,6 +65,7 @@ public enum ProviderDescriptorRegistry {
.copilot: CopilotProviderDescriptor.descriptor,
.zai: ZaiProviderDescriptor.descriptor,
.minimax: MiniMaxProviderDescriptor.descriptor,
+ .manus: ManusProviderDescriptor.descriptor,
.kimi: KimiProviderDescriptor.descriptor,
.kilo: KiloProviderDescriptor.descriptor,
.kiro: KiroProviderDescriptor.descriptor,
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index 63aa6221c..14e9ecb3f 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -12,6 +12,7 @@ public struct ProviderSettingsSnapshot: Sendable {
alibaba: AlibabaCodingPlanProviderSettings? = nil,
factory: FactoryProviderSettings? = nil,
minimax: MiniMaxProviderSettings? = nil,
+ manus: ManusProviderSettings? = nil,
zai: ZaiProviderSettings? = nil,
copilot: CopilotProviderSettings? = nil,
kilo: KiloProviderSettings? = nil,
@@ -34,6 +35,7 @@ public struct ProviderSettingsSnapshot: Sendable {
alibaba: alibaba,
factory: factory,
minimax: minimax,
+ manus: manus,
zai: zai,
copilot: copilot,
kilo: kilo,
@@ -154,6 +156,16 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct ManusProviderSettings: Sendable {
+ public let cookieSource: ProviderCookieSource
+ public let manualCookieHeader: String?
+
+ public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) {
+ self.cookieSource = cookieSource
+ self.manualCookieHeader = manualCookieHeader
+ }
+ }
+
public struct ZaiProviderSettings: Sendable {
public let apiRegion: ZaiAPIRegion
@@ -254,6 +266,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let alibaba: AlibabaCodingPlanProviderSettings?
public let factory: FactoryProviderSettings?
public let minimax: MiniMaxProviderSettings?
+ public let manus: ManusProviderSettings?
public let zai: ZaiProviderSettings?
public let copilot: CopilotProviderSettings?
public let kilo: KiloProviderSettings?
@@ -280,6 +293,7 @@ public struct ProviderSettingsSnapshot: Sendable {
alibaba: AlibabaCodingPlanProviderSettings?,
factory: FactoryProviderSettings?,
minimax: MiniMaxProviderSettings?,
+ manus: ManusProviderSettings?,
zai: ZaiProviderSettings?,
copilot: CopilotProviderSettings?,
kilo: KiloProviderSettings?,
@@ -301,6 +315,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.alibaba = alibaba
self.factory = factory
self.minimax = minimax
+ self.manus = manus
self.zai = zai
self.copilot = copilot
self.kilo = kilo
@@ -323,6 +338,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case alibaba(ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings)
case factory(ProviderSettingsSnapshot.FactoryProviderSettings)
case minimax(ProviderSettingsSnapshot.MiniMaxProviderSettings)
+ case manus(ProviderSettingsSnapshot.ManusProviderSettings)
case zai(ProviderSettingsSnapshot.ZaiProviderSettings)
case copilot(ProviderSettingsSnapshot.CopilotProviderSettings)
case kilo(ProviderSettingsSnapshot.KiloProviderSettings)
@@ -346,6 +362,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings?
public var factory: ProviderSettingsSnapshot.FactoryProviderSettings?
public var minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings?
+ public var manus: ProviderSettingsSnapshot.ManusProviderSettings?
public var zai: ProviderSettingsSnapshot.ZaiProviderSettings?
public var copilot: ProviderSettingsSnapshot.CopilotProviderSettings?
public var kilo: ProviderSettingsSnapshot.KiloProviderSettings?
@@ -372,6 +389,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .alibaba(value): self.alibaba = value
case let .factory(value): self.factory = value
case let .minimax(value): self.minimax = value
+ case let .manus(value): self.manus = value
case let .zai(value): self.zai = value
case let .copilot(value): self.copilot = value
case let .kilo(value): self.kilo = value
@@ -397,6 +415,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
alibaba: self.alibaba,
factory: self.factory,
minimax: self.minimax,
+ manus: self.manus,
zai: self.zai,
copilot: self.copilot,
kilo: self.kilo,
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 83dade054..c206725e4 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -15,6 +15,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case copilot
case zai
case minimax
+ case manus
case kimi
case kilo
case kiro
@@ -38,6 +39,7 @@ public enum IconStyle: Sendable, CaseIterable {
case claude
case zai
case minimax
+ case manus
case gemini
case antigravity
case cursor
diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
index a13d28a80..032f19976 100644
--- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
+++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
@@ -51,6 +51,13 @@ extension TokenAccountSupportCatalog {
injection: .cookieHeader,
requiresManualCookieSource: true,
cookieName: nil),
+ .manus: TokenAccountSupport(
+ title: "Session tokens",
+ subtitle: "Store multiple Manus session_id cookies.",
+ placeholder: "session_id=…",
+ injection: .cookieHeader,
+ requiresManualCookieSource: true,
+ cookieName: "session_id"),
.augment: TokenAccountSupport(
title: "Session tokens",
subtitle: "Store multiple Augment Cookie headers.",
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 17ddf1dba..a317a802f 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -234,7 +234,7 @@ enum CostUsageScanner {
}
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot,
- .minimax, .kilo, .kiro, .kimi,
+ .minimax, .manus, .kilo, .kiro, .kimi,
.kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus:
return emptyReport
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 73055305f..fec735409 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -63,6 +63,7 @@ enum ProviderChoice: String, AppEnum {
case .factory: return nil // Factory not yet supported in widgets
case .copilot: self = .copilot
case .minimax: self = .minimax
+ case .manus: return nil // Manus not yet supported in widgets
case .vertexai: return nil // Vertex AI not yet supported in widgets
case .kilo: self = .kilo
case .kiro: return nil // Kiro not yet supported in widgets
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 4e03801b3..149acab16 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -269,6 +269,7 @@ private struct ProviderSwitchChip: View {
case .factory: "Droid"
case .copilot: "Copilot"
case .minimax: "MiniMax"
+ case .manus: "Manus"
case .vertexai: "Vertex"
case .kilo: "Kilo"
case .kiro: "Kiro"
@@ -616,6 +617,8 @@ enum WidgetColors {
Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple
case .minimax:
Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255)
+ case .manus:
+ Color(red: 24 / 255, green: 24 / 255, blue: 24 / 255)
case .vertexai:
Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue
case .kilo:
diff --git a/Tests/CodexBarTests/ManusCookieHeaderTests.swift b/Tests/CodexBarTests/ManusCookieHeaderTests.swift
new file mode 100644
index 000000000..6f760d3d9
--- /dev/null
+++ b/Tests/CodexBarTests/ManusCookieHeaderTests.swift
@@ -0,0 +1,48 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct ManusCookieHeaderTests {
+ @Test
+ func `bare token resolves directly`() {
+ #expect(ManusCookieHeader.token(from: "abc123") == "abc123")
+ }
+
+ @Test
+ func `extracts session_id from cookie header`() {
+ let header = "foo=bar; session_id=token-a; baz=qux"
+ #expect(ManusCookieHeader.token(from: header) == "token-a")
+ }
+
+ @Test
+ func `extracts mixed case session id from cookie header`() {
+ let header = "foo=bar; Session_ID=token-b; baz=qux"
+ #expect(ManusCookieHeader.token(from: header) == "token-b")
+ }
+
+ @Test
+ func `unsupported cookie header returns nil`() {
+ #expect(ManusCookieHeader.token(from: "foo=bar; hello=world") == nil)
+ }
+
+ #if os(macOS)
+ @Test
+ func `importer session info extracts session token`() throws {
+ let cookies = try [
+ #require(self.makeCookie(name: "session_id", value: "cookie-token")),
+ ]
+ let session = ManusCookieImporter.SessionInfo(cookies: cookies, sourceLabel: "Chrome")
+ #expect(session.sessionToken == "cookie-token")
+ }
+
+ private func makeCookie(name: String, value: String) -> HTTPCookie? {
+ HTTPCookie(properties: [
+ .domain: "manus.im",
+ .path: "/",
+ .name: name,
+ .value: value,
+ .secure: "TRUE",
+ ])
+ }
+ #endif
+}
diff --git a/Tests/CodexBarTests/ManusProviderTests.swift b/Tests/CodexBarTests/ManusProviderTests.swift
new file mode 100644
index 000000000..be69fdf51
--- /dev/null
+++ b/Tests/CodexBarTests/ManusProviderTests.swift
@@ -0,0 +1,310 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct ManusProviderTests {
+ private static let now = Date(timeIntervalSince1970: 1_744_000_000)
+
+ private final class LockedArray: @unchecked Sendable {
+ private let lock = NSLock()
+ private var values: [Element] = []
+
+ func append(_ value: Element) {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ self.values.append(value)
+ }
+
+ func snapshot() -> [Element] {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ return self.values
+ }
+ }
+
+ private struct StubClaudeFetcher: ClaudeUsageFetching {
+ func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
+ throw ClaudeUsageError.parseFailed("stub")
+ }
+
+ func debugRawProbe(model _: String) async -> String {
+ "stub"
+ }
+
+ func detectVersion() -> String? {
+ nil
+ }
+ }
+
+ private func makeContext(
+ settings: ProviderSettingsSnapshot?,
+ env: [String: String] = [:]) -> ProviderFetchContext
+ {
+ ProviderFetchContext(
+ runtime: .app,
+ sourceMode: .auto,
+ includeCredits: false,
+ webTimeout: 1,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: env,
+ settings: settings,
+ fetcher: UsageFetcher(environment: env),
+ claudeFetcher: StubClaudeFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0))
+ }
+
+ private func stubResponse() -> ManusCreditsResponse {
+ ManusCreditsResponse(
+ totalCredits: 120,
+ freeCredits: 20,
+ periodicCredits: 80,
+ addonCredits: 10,
+ refreshCredits: 30,
+ maxRefreshCredits: 300,
+ proMonthlyCredits: 100,
+ eventCredits: 10,
+ nextRefreshTime: Date(timeIntervalSince1970: 1_744_003_600),
+ refreshInterval: "daily")
+ }
+
+ private func withIsolatedCacheStore(operation: () async throws -> T) async rethrows -> T {
+ let service = "manus-provider-tests-\(UUID().uuidString)"
+ return try await KeychainCacheStore.withServiceOverrideForTesting(service) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+ return try await operation()
+ }
+ }
+
+ @Test
+ func `off mode ignores environment session token`() async {
+ let strategy = ManusWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ manus: ProviderSettingsSnapshot.ManusProviderSettings(
+ cookieSource: .off,
+ manualCookieHeader: nil))
+ let context = self.makeContext(
+ settings: settings,
+ env: ["MANUS_SESSION_TOKEN": "env-token"])
+
+ #expect(await strategy.isAvailable(context) == false)
+ }
+
+ @Test
+ func `manual mode invalid cookie does not fall back to cache or environment`() async {
+ await self.withIsolatedCacheStore {
+ CookieHeaderCache.store(
+ provider: .manus,
+ cookieHeader: "session_id=cached-token",
+ sourceLabel: "web")
+
+ let strategy = ManusWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ manus: ProviderSettingsSnapshot.ManusProviderSettings(
+ cookieSource: .manual,
+ manualCookieHeader: "foo=bar"))
+ let context = self.makeContext(
+ settings: settings,
+ env: ["MANUS_SESSION_TOKEN": "env-token"])
+
+ do {
+ _ = try await strategy.fetch(context)
+ Issue.record("Expected invalid manual cookie instead of falling back to cache/environment")
+ } catch let error as ManusAPIError {
+ #expect(error == .invalidCookie)
+ } catch {
+ Issue.record("Expected ManusAPIError.invalidCookie, got \(error)")
+ }
+ }
+ }
+
+ @Test
+ func `environment token does not populate browser cache`() async throws {
+ try await self.withIsolatedCacheStore {
+ #if os(macOS)
+ ManusCookieImporter.importSessionsOverrideForTesting = { _, _ in
+ throw ManusCookieImportError.noCookies
+ }
+ ManusCookieImporter.importSessionOverrideForTesting = nil
+ defer {
+ ManusCookieImporter.importSessionsOverrideForTesting = nil
+ ManusCookieImporter.importSessionOverrideForTesting = nil
+ }
+ #endif
+
+ let strategy = ManusWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ manus: ProviderSettingsSnapshot.ManusProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil))
+ let context = self.makeContext(
+ settings: settings,
+ env: ["MANUS_SESSION_TOKEN": "env-token"])
+ let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in
+ #expect(token == "env-token")
+ return self.stubResponse()
+ }
+
+ _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
+ try await strategy.fetch(context)
+ })
+
+ #expect(CookieHeaderCache.load(provider: .manus) == nil)
+ }
+ }
+
+ #if os(macOS)
+ @Test
+ func `invalid browser token falls back to environment token`() async throws {
+ try await self.withIsolatedCacheStore {
+ let browserCookie = try #require(HTTPCookie(properties: [
+ .domain: "manus.im",
+ .path: "/",
+ .name: "session_id",
+ .value: "browser-token",
+ .secure: "TRUE",
+ ]))
+ ManusCookieImporter.importSessionOverrideForTesting = { _, _ in
+ ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome")
+ }
+ ManusCookieImporter.importSessionsOverrideForTesting = nil
+ defer {
+ ManusCookieImporter.importSessionOverrideForTesting = nil
+ ManusCookieImporter.importSessionsOverrideForTesting = nil
+ }
+
+ let attempts = LockedArray()
+ let strategy = ManusWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ manus: ProviderSettingsSnapshot.ManusProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil))
+ let context = self.makeContext(
+ settings: settings,
+ env: ["MANUS_SESSION_TOKEN": "env-token"])
+ let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in
+ attempts.append(token)
+ if token == "browser-token" {
+ throw ManusAPIError.invalidToken
+ }
+ #expect(token == "env-token")
+ return self.stubResponse()
+ }
+
+ _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
+ try await strategy.fetch(context)
+ })
+
+ #expect(attempts.snapshot() == ["browser-token", "env-token"])
+ #expect(CookieHeaderCache.load(provider: .manus) == nil)
+ }
+ }
+
+ @Test
+ func `browser token populates cache after successful fetch`() async throws {
+ try await self.withIsolatedCacheStore {
+ let browserCookie = try #require(HTTPCookie(properties: [
+ .domain: "manus.im",
+ .path: "/",
+ .name: "session_id",
+ .value: "browser-token",
+ .secure: "TRUE",
+ ]))
+ ManusCookieImporter.importSessionOverrideForTesting = { _, _ in
+ ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome")
+ }
+ ManusCookieImporter.importSessionsOverrideForTesting = nil
+ defer {
+ ManusCookieImporter.importSessionOverrideForTesting = nil
+ ManusCookieImporter.importSessionsOverrideForTesting = nil
+ }
+
+ let strategy = ManusWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ manus: ProviderSettingsSnapshot.ManusProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil))
+ let context = self.makeContext(settings: settings)
+ let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in
+ #expect(token == "browser-token")
+ return self.stubResponse()
+ }
+
+ _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
+ try await strategy.fetch(context)
+ })
+
+ let cached = CookieHeaderCache.load(provider: .manus)
+ #expect(cached?.cookieHeader == "session_id=browser-token")
+ }
+ }
+ #endif
+
+ @Test
+ func `settings reader accepts full cookie header from environment`() {
+ let env = ["MANUS_COOKIE": "foo=bar; session_id=env-cookie-token; baz=qux"]
+ #expect(ManusSettingsReader.sessionToken(environment: env) == "env-cookie-token")
+ }
+
+ @Test
+ func `parse response tolerates sparse live payload`() throws {
+ let data = Data("""
+ {
+ "totalCredits": 2869,
+ "freeCredits": 1500,
+ "periodicCredits": 1369,
+ "proMonthlyCredits": 4000,
+ "maxRefreshCredits": 300,
+ "nextRefreshTime": "2026-04-13T00:00:00Z",
+ "refreshInterval": "daily",
+ "userFlag": { "drc16": true }
+ }
+ """.utf8)
+
+ let response = try ManusUsageFetcher.parseResponse(data)
+ #expect(response.totalCredits == 2869)
+ #expect(response.periodicCredits == 1369)
+ #expect(response.proMonthlyCredits == 4000)
+ #expect(response.refreshCredits == 0)
+ #expect(response.addonCredits == 0)
+ #expect(response.maxRefreshCredits == 300)
+ #expect(response.nextRefreshTime != nil)
+
+ let snapshot = response.toUsageSnapshot(now: Self.now)
+ #expect(snapshot.providerCost == nil)
+ #expect(snapshot.primary?.usedPercent ?? 0 > 65)
+ #expect(snapshot.primary?.resetDescription == "Total 2,869 • Free 1,500")
+ #expect(snapshot.secondary?.usedPercent == 100)
+ #expect(snapshot.secondary?.resetDescription == "Daily: 0 / 300")
+ }
+
+ @Test
+ func `parse response rejects payload without credits fields`() {
+ let data = Data(#"{"error":"unauthorized","message":"session expired"}"#.utf8)
+
+ #expect(throws: ManusAPIError.self) {
+ try ManusUsageFetcher.parseResponse(data)
+ }
+ }
+
+ @Test
+ func `parse response accepts wrapped envelope`() throws {
+ let data = Data("""
+ {
+ "data": {
+ "totalCredits": 100,
+ "proMonthlyCredits": 200,
+ "periodicCredits": 50,
+ "maxRefreshCredits": 10,
+ "refreshCredits": 5
+ }
+ }
+ """.utf8)
+
+ let response = try ManusUsageFetcher.parseResponse(data)
+ #expect(response.totalCredits == 100)
+ #expect(response.periodicCredits == 50)
+ }
+}
diff --git a/docs/manus.md b/docs/manus.md
new file mode 100644
index 000000000..7ee919846
--- /dev/null
+++ b/docs/manus.md
@@ -0,0 +1,73 @@
+---
+summary: "Manus provider: browser session_id cookie auth for credit balance, monthly credits, and daily refresh tracking."
+read_when:
+ - Adding or modifying the Manus provider
+ - Debugging Manus cookie imports or API responses
+ - Adjusting Manus usage display or credit formatting
+---
+
+# Manus Provider
+
+The Manus provider tracks credit usage on [manus.im](https://manus.im) via browser `session_id` cookie authentication.
+
+## Features
+
+- **Monthly credit gauge**: Shows Pro monthly credits used vs. plan total (`proMonthlyCredits` − `periodicCredits`).
+- **Daily refresh gauge**: Shows daily refresh credits used vs. max refresh allotment, with reset timing.
+- **Balance display**: Total credits available, shown in the menu identity line.
+- **Cookie auth**: Automatic browser cookie import (Safari, Chrome, Firefox) or manual cookie header.
+- **Env var support**: `MANUS_SESSION_TOKEN` (raw token) or `MANUS_COOKIE` (full cookie header) for CLI/headless usage.
+
+## Setup
+
+1. Open **Settings → Providers**
+2. Enable **Manus**
+3. Log in to [manus.im](https://manus.im) in your browser
+4. Cookie import happens automatically on the next refresh
+
+### Manual cookie mode
+
+1. In **Settings → Providers → Manus**, set Cookie source to **Manual**
+2. Open your browser DevTools on `manus.im`, copy the `Cookie:` header from any API request (must contain `session_id=...`)
+3. Paste the header into the cookie field in CodexBar
+
+### Environment variables (CLI / headless)
+
+- `MANUS_SESSION_TOKEN`: the raw `session_id` value.
+- `MANUS_COOKIE`: a full cookie header; the provider extracts `session_id` from it.
+
+Either works; raw-token form is preferred when only one value is needed.
+
+## How it works
+
+A single API endpoint is fetched with a bearer token derived from the `session_id` cookie value:
+
+- `POST https://api.manus.im/user.v1.UserService/GetAvailableCredits` — returns credit fields including `totalCredits`, `freeCredits`, `periodicCredits`, `proMonthlyCredits`, `refreshCredits`, `maxRefreshCredits`, `nextRefreshTime`, and `refreshInterval`.
+
+Cookie domain: `manus.im`. Valid `session_id` cookies are cached in Keychain and reused until the session expires.
+
+The response parser tolerates both a direct object and common envelope shapes (`data` / `result` / `response` / `availableCredits`). Payloads missing all expected credit fields are rejected as a parse error rather than surfacing a misleading zero-credit snapshot.
+
+## Token accounts
+
+Manus supports multiple accounts via the standard token-account mechanism. Add entries to `~/.codexbar/config.json` (`tokenAccounts`) with the full `Cookie:` header (containing `session_id=...`), then switch between accounts from the menu.
+
+## CLI
+
+```bash
+codexbar usage --provider manus --verbose
+```
+
+## Troubleshooting
+
+### "No Manus session token provided"
+
+Log in to [manus.im](https://manus.im) in a supported browser (Safari, Chrome, Firefox), then refresh CodexBar. Alternatively, set `MANUS_SESSION_TOKEN` or `MANUS_COOKIE`, or paste a cookie header in manual mode.
+
+### "Invalid Manus session token"
+
+Your session has expired or been revoked. Log out and back in to Manus, or paste a fresh `Cookie:` header in manual mode.
+
+### "Response missing expected credits fields"
+
+The API returned a 200 response that doesn't look like a credits payload (often an error object). Re-login to Manus; if it persists, the upstream response schema may have changed.
diff --git a/docs/providers.md b/docs/providers.md
index a82898e78..6d8c47427 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -1,5 +1,5 @@
---
-summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI)."
+summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Manus, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI)."
read_when:
- Adding or modifying provider fetch/parsing
- Adjusting provider labels, toggles, or metadata
@@ -27,6 +27,7 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). |
| Droid/Factory | Web cookies → stored tokens → local storage → WorkOS cookies (`web`). |
| z.ai | API token (Keychain/env) → quota API (`api`). |
+| Manus | Browser `session_id` cookie (auto/manual/env) → credits API (`web`). |
| MiniMax | Manual cookie header (Keychain/env) → browser cookies (+ local storage access token) → coding plan page (HTML) with remains API fallback (`web`). |
| Kimi | API token (JWT from `kimi-auth` cookie) → usage API (`api`). |
| Kilo | API token (`KILO_API_KEY`) → usage API (`api`); auto falls back to CLI session auth (`cli`). |
@@ -63,6 +64,12 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Status: none yet.
- Details: `docs/zai.md`.
+## Manus
+- Session token via browser `session_id` cookie, manual Settings entry, `MANUS_SESSION_TOKEN`, or `MANUS_COOKIE`.
+- Credits endpoint: `POST https://api.manus.im/user.v1.UserService/GetAvailableCredits`.
+- Auto mode prefers cached/browser cookies before env fallback; manual mode accepts either a bare `session_id` value or a full Cookie header.
+- Status: none yet.
+
## MiniMax
- Session cookie header from Keychain or `MINIMAX_COOKIE`/`MINIMAX_COOKIE_HEADER` env var.
- Hosts: `platform.minimax.io` (global) or `platform.minimaxi.com` (China mainland) via region picker or `MINIMAX_HOST`; full overrides via `MINIMAX_CODING_PLAN_URL` / `MINIMAX_REMAINS_URL`.