quota/getQuotaAntigravity.mjs

import fs from 'fs'
import os from 'os'
import path from 'path'
import crypto from 'crypto'
import get from 'lodash-es/get.js'
import isestr from 'wsemi/src/isestr.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import execCli from 'wsemi/src/execCli.mjs'
import strCompareForVersion from 'wsemi/src/strCompareForVersion.mjs'
import castPintOr from '../castPintOr.mjs'
import toQuotaLabel from './toQuotaLabel.mjs'
import toQuotaWindow from './toQuotaWindow.mjs'
import toQuotaResult from './toQuotaResult.mjs'


// getQuotaAntigravity.mjs — 查詢Antigravity(Google側, agy CLI)帳號之當前額度
//
// 【資料來源: agy自身之print模式(官方、無頭、不碰憑證), 2026-09-05本機實測(agy 1.1.27)】
//   `agy -p "/usage" --output-format json` 回 {status:'SUCCESS', num_turns:0, usage:{全零},
//     command:{name:'usage', data:{groups:[{name:'Gemini Models', buckets:[
//       {id:'gemini-weekly', window:'weekly', remaining_fraction, reset_time, description}, {id:'gemini-5h', window:'5h', ...}]},
//       {name:'Claude and GPT models', buckets:[{id:'3p-weekly',...},{id:'3p-5h',...}]}]}}}
//   agy CHANGELOG 1.1.11原文: 「-p "/usage", /quota, /credits ... emit a structured payload under
//   --output-format json ... without starting an agent turn, spending quota, or leaving a conversation behind」。
//   帳號email: 同一次spawn加`--log-file <暫存檔>`, log內有`applyAuthResult: email=..., authMethod=consumer`。
//   耗時實測5~8秒(agy啟動、認證、loadCodeAssist與fetchAvailableModels兩個後端往返), 故預設逾時較寬。
//
// 【為何不走Gemini CLI憑證或Code Assist端點】Google已於2026-06-18終止Gemini CLI對個人層之服務;
//   ~/.gemini/google_accounts.json與oauth_creds.json為舊Gemini CLI所留, 實測其帳號(firsemisphere6)
//   與agy實際登入帳號(firsemisphere)不同, 且打Code Assist端點回403; 同類工具為此各自借token
//   (讀IDE之state.vscdb、讀Windows認證管理員、掃agy二進位取client secret、自建OAuth登入),
//   全屬未公開之內部格式與灰色地帶。agy既已提供官方無頭介面, 上述路徑一概不做。
//
// 【版本閘門】1.1.11之前的agy會把「/usage」當一般prompt起一個agent turn(耗額度、留對話),
//   故先以`agy --version`把關(實測<1秒), 低於門檻即回unsupported而非冒險執行。
//
// 【Git Bash陷阱(僅影響手動測試)】MSYS會把以斜線開頭之參數「/usage」轉譯成Windows路徑
//   (C:/Program Files/Git/usage), agy收到路徑字串就當成prompt執行; Node之spawn無此轉譯,
//   本函數不受影響, 但在Git Bash手動驗證須加MSYS_NO_PATHCONV=1。


//預設值
let DEFAULT_EXE = 'agy'
let DEFAULT_TIMEOUT_MS = 60000
let MIN_VERSION = '1.1.11'


//各窗口字串對應之秒數; 未列者以正則解析Nh/Nd, 皆不成則null(標籤退回桶名)
let WINDOW_SECONDS = {
    '5h': 18000,
    'five_hour': 18000,
    'five-hour': 18000,
    'weekly': 604800,
    'week': 604800,
    'daily': 86400,
    'day': 86400,
}


/**
 * 由桶之window字串推得窗口秒數
 *
 * @param {String} w 輸入window字串, 例如'5h'、'weekly'
 * @returns {Number|null} 回傳秒數, 無法推得回傳null
 */
function windowToSeconds(w) {
    if (!isestr(w)) {
        return null
    }
    let k = w.trim().toLowerCase()
    if (WINDOW_SECONDS[k] !== undefined) {
        return WINDOW_SECONDS[k]
    }
    let m = k.match(/^(\d+)\s*(h|hr|hour|hours|d|day|days|m|min|minutes)$/)
    if (!m) {
        return null
    }
    let n = Number(m[1])
    if (/^h/.test(m[2])) {
        return n * 3600
    }
    if (/^d/.test(m[2])) {
        return n * 86400
    }
    return n * 60
}


/**
 * 將群組內之單一桶正規化為統一窗口物件
 *
 * @param {Object} g 輸入群組物件
 * @param {Object} b 輸入桶物件
 * @returns {Object|null} 回傳統一窗口物件, 桶無id或無剩餘比例回傳null
 */
function fromBucket(g, b) {

    let key = get(b, 'id', '')
    if (!isestr(key)) {
        return null
    }

    //usedPercent, 由剩餘比例反推; 文件載明可能為數值或字串故一律轉數值
    let rf = get(b, 'remaining_fraction', null)
    let usedPercent = null
    if (isnum(rf)) {
        usedPercent = Math.round((1 - cdbl(rf)) * 100 * 100) / 100
    }

    //scope與label, 群組名為範圍; 窗口秒數可推得時標籤為「5小時(Gemini Models)」, 否則退回桶名
    let scope = get(g, 'name', '')
    if (!isestr(scope)) {
        scope = ''
    }
    let windowSeconds = windowToSeconds(get(b, 'window', ''))
    let base = toQuotaLabel(windowSeconds)
    if (base === '') {
        base = get(b, 'name', '')
        if (!isestr(base)) {
            base = key
        }
    }
    let label = scope === '' ? base : `${base}(${scope})`

    //severity, 用罄與停用須明示; 其餘normal
    let severity = 'normal'
    if (get(b, 'disabled', false) === true) {
        severity = 'disabled'
    }
    else if (usedPercent !== null && usedPercent >= 100) {
        severity = 'exhausted'
    }

    return toQuotaWindow({
        key,
        label,
        windowSeconds,
        usedPercent,
        resetAt: get(b, 'reset_time', ''),
        scope,
        active: false,
        severity,
    })
}


/**
 * 執行agy之print模式讀取型slash指令並解析其JSON回應
 *
 * @param {String} exe 輸入agy執行檔
 * @param {String} cmd 輸入slash指令字串, 例如'/usage'
 * @param {Object} o 輸入設定物件{timeoutMs, cwd, logFile}
 * @returns {Promise} 回傳物件{ok, data, response, stderr, error, errorType, code}
 */
async function agyPrint(exe, cmd, o) {

    let args = []
    if (isestr(o.logFile)) {
        args.push('--log-file', o.logFile)
    }
    args.push('-p', cmd, '--output-format', 'json', '--print-timeout', `${Math.max(5, Math.ceil(o.timeoutMs / 1000))}s`)

    let r = await execCli(exe, args, { timeoutMs: o.timeoutMs, cwd: o.cwd })

    //stdout最後一行非空者為JSON
    let last = String(get(r, 'stdout', '')).trim().split('\n').map((s) => s.trim()).filter((s) => s !== '').pop() || ''
    let j = null
    try {
        j = JSON.parse(last)
    }
    catch (err) {}

    if (!r.ok) {
        let tail = String(get(r, 'stderr', '')).trim().slice(-300)
        let errorType = /timeout/i.test(get(r, 'error', '')) ? 'timeout' : 'exit'
        if (/not logged in|login|sign in|authenticate/i.test(tail)) {
            errorType = 'auth'
        }
        return { ok: false, data: null, response: '', stderr: tail, error: `agy ${cmd} failed: ${get(r, 'error', '')}${tail ? ` | stderr: ${tail}` : ''}`, errorType, code: get(r, 'code', null) }
    }

    if (j === null) {
        return { ok: false, data: null, response: '', stderr: '', error: `agy ${cmd} stdout is not JSON: ${last.slice(0, 200)}`, errorType: 'parse', code: r.code }
    }

    //防呆: 若agy把指令當成prompt執行(num_turns>0或無command), 代表版本過舊或slash展開被停用
    let numTurns = get(j, 'num_turns', 0)
    let cname = get(j, 'command.name', '')
    if (!isestr(cname) || (isnum(numTurns) && cdbl(numTurns) > 0)) {
        return { ok: false, data: null, response: get(j, 'response', ''), stderr: '', error: `agy did not handle ${cmd} as a slash command (num_turns=${numTurns}); version may be below ${MIN_VERSION} or --disable-slash-commands is set; this call may have consumed quota`, errorType: 'unsupported', code: r.code }
    }

    if (get(j, 'status', '') !== 'SUCCESS') {
        return { ok: false, data: get(j, 'command.data', null), response: get(j, 'response', ''), stderr: '', error: `agy ${cmd} reported status=${get(j, 'status', '')}: ${String(get(j, 'response', '')).slice(0, 200)}`, errorType: 'exit', code: r.code }
    }

    return { ok: true, data: get(j, 'command.data', null), response: get(j, 'response', ''), stderr: '', error: '', errorType: '', code: r.code, raw: j }
}


/**
 * 查詢Antigravity(Google側, agy CLI)帳號之當前額度
 *
 * 以agy自身之print模式取得額度(官方、無頭、不碰憑證):`agy -p "/usage" --output-format json`,
 * 並以同一次執行之`--log-file`取得登入帳號email。
 * 額度綁定本機agy之登入憑證,email參數之作用為比對——不符時ok為false且matched為false;
 * 未給email時不比對。本函數不會reject,一律以結果物件之ok與error欄位回報成敗
 *
 * @param {String} [email=''] 輸入欲查詢之帳號email字串,預設''代表不比對而直接回報本機當前帳號
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.exe='agy'] 輸入agy執行檔名稱或路徑字串,預設'agy'
 * @param {Boolean} [opt.checkVersion=true] 輸入是否先以`agy --version`把關版本布林值,低於1.1.11即回unsupported而不執行(舊版會把/usage當prompt跑而耗額度),預設true
 * @param {Boolean} [opt.withCredits=false] 輸入是否另執行`/credits`取得G1點數餘額布林值,會多一次約5秒之啟動,預設false
 * @param {String} [opt.cwd=os.tmpdir()] 輸入子進程工作目錄字串,預設系統暫存目錄以免在專案目錄留下痕跡
 * @param {Number} [opt.timeoutMs=60000] 輸入單次agy執行之逾時毫秒正整數,預設60000(agy啟動加兩個後端往返實測5~8秒)
 * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok、provider('antigravity')、email(agy登入帳號)、matched、plan(空字串,/usage不提供方案別)、planTier(空字串)、source('agy-print-usage')、windows(每群組之5小時與7天窗口,scope為群組名)、credits(withCredits時為{remainingCredits, upgradeUri},否則null)、raw(含agy版本、authMethod、原始回應)、error、errorType、durationMs,本函數不會reject
 * @example
 * //need agy cli (>=1.1.11) logged in
 *
 * import getQuotaAntigravity from './src/quota/getQuotaAntigravity.mjs'
 *
 * let test = async () => {
 *     let r = await getQuotaAntigravity('firsemisphere@gmail.com')
 *     console.log(r.ok, r.email)
 *     // => true firsemisphere@gmail.com
 *     console.log(r.windows.map((w) => `${w.label}:${w.remainingPercent}%`).join(' '))
 *     // => 7天(Gemini Models):85% 5小時(Gemini Models):90% 7天(Claude and GPT models):100% 5小時(Claude and GPT models):100% (即時值)
 * }
 * test()
 *
 */
async function getQuotaAntigravity(email = '', opt = {}) {

    let t0 = Date.now()
    let source = 'agy-print-usage'

    //emailWant
    let emailWant = isestr(email) ? email : ''

    //exe
    let exe = get(opt, 'exe', null)
    if (!isestr(exe)) {
        exe = DEFAULT_EXE
    }

    //timeoutMs
    let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), DEFAULT_TIMEOUT_MS)

    //cwd
    let cwd = get(opt, 'cwd', null)
    if (!isestr(cwd)) {
        cwd = os.tmpdir()
    }

    //checkVersion與withCredits
    let checkVersion = get(opt, 'checkVersion', true) !== false
    let withCredits = get(opt, 'withCredits', null)
    if (!isbol(withCredits)) {
        withCredits = false
    }

    //fin
    let fin = (o) => {
        return toQuotaResult('antigravity', {
            emailWant,
            source,
            durationMs: Date.now() - t0,
            ...o,
        })
    }

    //version gate
    let version = ''
    if (checkVersion) {
        let rv = await execCli(exe, ['--version'], { timeoutMs: Math.min(timeoutMs, 15000), cwd })
        if (!rv.ok) {
            return fin({
                error: `agy not found or not executable (${exe}): ${get(rv, 'error', '')}${String(get(rv, 'stderr', '')).trim() ? ` | ${String(rv.stderr).trim().slice(-200)}` : ''}; see https://antigravity.google/docs/cli/ for install and login`,
                errorType: 'notfound',
            })
        }
        version = String(get(rv, 'stdout', '')).trim().split('\n')[0]

        //strCompareForVersion可直接餵--version原始輸出; 解析不出三段版本號回null, 一律視為不達門檻
        let cmp = strCompareForVersion(version, MIN_VERSION)
        if (cmp === null || cmp < 0) {
            return fin({
                error: `agy version [${version || 'unknown'}] is below ${MIN_VERSION}; its print mode cannot run /usage non-interactively (it would treat it as a prompt and consume quota); run [agy update]`,
                errorType: 'unsupported',
                raw: { version },
            })
        }
    }

    //logFile, 供取得登入帳號; 落系統暫存目錄, 結束必刪
    let logFile = path.join(os.tmpdir(), `w-dispatch-ai-agy-${process.pid}-${crypto.randomBytes(4).toString('hex')}.log`)

    let ru = null
    let rc = null
    let emailHave = ''
    let authMethod = ''
    try {

        //usage
        ru = await agyPrint(exe, '/usage', { timeoutMs, cwd, logFile })

        //email, 自log取得(agy於認證後寫入applyAuthResult行)
        let log = ''
        try {
            log = fs.readFileSync(logFile, 'utf8')
        }
        catch (err) {}
        let m = log.match(/applyAuthResult: email=([^,\s]+), authMethod=(\w+)/)
        if (m) {
            emailHave = m[1]
            authMethod = m[2]
        }
        else {
            let m2 = log.match(/authenticated successfully as (\S+)/)
            if (m2) {
                emailHave = m2[1]
            }
        }

        //credits, 選配
        if (ru.ok && withCredits) {
            rc = await agyPrint(exe, '/credits', { timeoutMs, cwd, logFile: '' })
        }

    }
    finally {
        try {
            fs.unlinkSync(logFile)
        }
        catch (err) {}
    }

    if (!ru.ok) {
        return fin({
            email: emailHave,
            error: ru.error,
            errorType: ru.errorType,
            raw: { version, authMethod, response: ru.response, stderr: ru.stderr },
        })
    }

    //windows
    let windows = []
    let groups = get(ru.data, 'groups', null)
    if (isearr(groups)) {
        for (let g of groups) {
            let buckets = get(g, 'buckets', null)
            if (!isearr(buckets)) {
                continue
            }
            for (let b of buckets) {
                let w = fromBucket(g, b)
                if (w !== null) {
                    windows.push(w)
                }
            }
        }
    }

    //credits
    let credits = null
    if (rc !== null) {
        credits = rc.ok
            ? { remainingCredits: get(rc.data, 'remaining_credits', null), upgradeUri: get(rc.data, 'upgrade_uri', '') }
            : { error: rc.error }
    }

    return fin({
        email: emailHave,
        windows,
        credits,
        raw: {
            version,
            authMethod,
            description: get(ru.data, 'description', ''),
            usage: ru.raw,
            credits: rc !== null ? get(rc, 'raw', null) : null,
        },
    })
}


export default getQuotaAntigravity