UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

301 lines • 12.7 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { CommandSpec } from "../../../spec/types.js"; import { CLIHandler } from "../../types.js"; import { driveBase, googleGet, googleGetStream, googlePatch, googleRequest, } from "../../../../core/google/client.js"; import { IOResult } from "../../../../io/types.js"; import { FlagView } from "../../../spec/flag_view.js"; import { Argument } from "../../../spec/types.js"; import { GWS_METHODS, SERVICE_BASES, gwsMethodDescription } from "./methods.js"; const ENC = new TextEncoder(); const PARAMS_HELP = 'JSON object of path and query parameters, e.g. \'{"fileId":"abc"}\''; const JSON_HELP = 'JSON request body, the API resource for this method'; const PAGE_ALL_HELP = 'Follow nextPageToken to the end (the default); pages print as one JSON response per line'; const PAGE_LIMIT_HELP = 'Stop after this many pages instead of reading them all'; const API_OPTIONS = [ new Argument('--params', { help: PARAMS_HELP }), new Argument('--json', { help: JSON_HELP }), new Argument('--page-all', { action: 'store_true', help: PAGE_ALL_HELP }), new Argument('--page-limit', { help: PAGE_LIMIT_HELP }), ]; function parseJsonFlag(value, flag) { if (value === undefined || value === null || value === '') return {}; if (typeof value !== 'string') throw new Error(`${flag} must be a JSON string`); let parsed; try { parsed = JSON.parse(value); } catch { // One wording in both languages: the engines' own parse messages // ("Expecting value" vs "Unexpected token") can never agree. throw new Error(`${flag} must be valid JSON`); } if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`${flag} must be a JSON object`); } return parsed; } // Substitute `{placeholder}` segments from params; consumed keys are // removed and the leftovers become query parameters. export function fillPath(template, params) { const consumed = new Set(); let path = template; for (;;) { const start = path.indexOf('{'); if (start === -1) break; const end = path.indexOf('}', start); const name = path.slice(start + 1, end); if (!(name in params)) throw new Error(`--params must contain ${name}`); // Percent-encode the value: a Discovery path parameter is one segment, // and several real ids carry characters that change what the URL means. // A Google holiday calendar id // ("en.usa#holiday@group.v.calendar.google.com") is the sharp case, // since "#" opens a fragment and the request would reach // /calendars/en.usa instead. path = path.slice(0, start) + encodeURIComponent(String(params[name])) + path.slice(end + 1); consumed.add(name); } const query = Object.fromEntries(Object.entries(params).filter(([k]) => !consumed.has(k))); return [path, query]; } function queryStr(query) { const out = {}; for (const [k, v] of Object.entries(query)) { out[k] = typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v); } return out; } function withQuery(url, query) { const pairs = new URLSearchParams(query).toString(); if (pairs === '') return url; return url + (url.includes('?') ? '&' : '?') + pairs; } /** * Default a create's parents to the installation's folder scope. * * A folder-scoped install is pointed at one folder, which is the same folder a * gdrive mount sharing the config exposes. A create with no parents would land * in My Drive's root instead, outside the mount, so the agent's own `ls` would * never show what it just made. An explicit parents array always wins, and * "explicit" means the key is present, not that it holds anything: * `"parents": []` is a caller saying where the file goes just as much as * `"parents": ["root"]` is, and reading it as absent would silently relocate * their file. * * The injected parent brings `supportsAllDrives` with it, because a folder scope * may name a Shared Drive folder and Drive rejects a create into one from a * client that has not declared itself shared drive aware. Only the injected case * adds it: a caller who typed their own parents is making a passthrough call and * owns its query, the same way the official CLI leaves it to them. */ function scopeRequest(method, config, body, params) { if (method.placement !== 'parents') return [body, params]; const folderId = config.folderId; if (folderId === undefined || folderId === '') return [body, params]; if ('parents' in body) return [body, params]; // params last: an explicitly typed supportsAllDrives still wins. return [ { ...body, parents: [folderId] }, { supportsAllDrives: true, ...params }, ]; } /** * Move a newly created editor file into the folder scope. * * The Docs, Sheets and Slides create methods have no parents field at all, so a * new file always lands in My Drive's root; placing it takes a second Drive * call. Doing it here is what lets `gws sheets spreadsheets create` put the file * where the mount is, instead of leaving the caller to know that placement was * even a question. * * `supportsAllDrives` rides along for the same reason every other Drive helper * in the repo sends it: without it a scope naming a Shared Drive folder fails * the move, and the create has already happened, so the file would be stranded * in My Drive. */ async function placeInScope(method, tm, result) { const folderId = tm.config.folderId; const fileId = result[method.idField ?? 'id']; if (folderId === undefined || folderId === '' || typeof fileId !== 'string') return; await googlePatch(tm, `${driveBase(tm)}/files/${fileId}`, {}, { addParents: folderId, removeParents: 'root', supportsAllDrives: 'true' }); } export async function runGwsMethod(method, accessor, _paths, _texts, ctx) { const fl = new FlagView(ctx.flags); let params; let body; try { params = parseJsonFlag(fl.asStr('params') ?? '', '--params'); body = parseJsonFlag(fl.asStr('json') ?? '', '--json'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return [null, new IOResult({ exitCode: 2, stderr: ENC.encode(`${msg}\n`) })]; } if (method.needsBody === true && Object.keys(body).length === 0) { return [null, new IOResult({ exitCode: 2, stderr: ENC.encode('--json is required\n') })]; } ; [body, params] = scopeRequest(method, accessor.tokenManager.config, body, params); const tm = accessor.tokenManager; let path; let query; try { ; [path, query] = fillPath(method.path, params); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return [null, new IOResult({ exitCode: 2, stderr: ENC.encode(`${msg}\n`) })]; } const url = SERVICE_BASES[method.service](tm) + path; const queryParams = queryStr(query); if (method.rawBytes === true) { const data = googleGetStream(tm, withQuery(url, queryParams)); return [data, new IOResult()]; } if (method.http === 'GET') { let pageLimit; try { pageLimit = parsePageLimit(fl.asStr('page_limit')); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return [null, new IOResult({ exitCode: 2, stderr: ENC.encode(`${msg}\n`) })]; } // Deliberate divergence from the official gws CLI, which stops at one // page unless --page-all is passed: a truncated listing is // indistinguishable from a complete one, so mirage follows the token by // default and --page-limit is how you opt out. const out = await paginate(tm, url, queryParams, pageLimit); return [out, new IOResult()]; } const deleting = method.http === 'DELETE'; const result = await googleRequest(tm, method.http, url, { params: queryParams, ...(deleting ? { read: 'none' } : { json: body }), }); if (deleting) return [null, new IOResult()]; if (method.placement === 'relocate' && result !== null && typeof result === 'object' && !Array.isArray(result)) { await placeInScope(method, tm, result); } return [ENC.encode(JSON.stringify(result)), new IOResult()]; } function parsePageLimit(raw) { if (typeof raw !== 'string' || raw === '') return null; if (!/^\d+$/.test(raw)) throw new Error(`--page-limit must be a whole number, got '${raw}'`); return Number(raw); } // Google list methods cap a page and hand back a token; a single call // silently returns a partial listing. Pages are emitted as NDJSON so a // caller can pipe straight into `jq`, which evaluates per document. async function paginate(tm, url, query, pageLimit) { const pages = []; let token = null; let fetched = 0; for (;;) { // A fresh object per page: the callers keep the mapping they are handed, // so a mutated one would let a later token leak backwards. const params = { ...query }; if (token !== null) params.pageToken = token; const result = await googleGet(tm, url, params); pages.push(JSON.stringify(result)); fetched += 1; if (pageLimit !== null && fetched >= pageLimit) break; const next = result !== null && typeof result === 'object' ? result.nextPageToken : undefined; // Google always sends a string token; anything else is not one, and // stringifying it would send a request that can only 400. if (typeof next !== 'string' && typeof next !== 'number') break; if (next === '') break; token = String(next); } // A single response keeps the exact bytes an unpaginated call produced, so // every non-list GET is unchanged. Only a real multi-page stream is // newline-terminated, per the NDJSON convention. const out = pages.join('\n'); return ENC.encode(pages.length > 1 ? out + '\n' : out); } function methodLeaf(method) { return new CommandSpec({ name: method.method, description: gwsMethodDescription(method), arguments: [...API_OPTIONS], }); } function buildGroup(name, node) { const leaves = node.methods.map((m) => methodLeaf(m)); const groups = [...node.children.entries()].map(([child, sub]) => buildGroup(child, sub)); return new CommandSpec({ name, description: `Google API ${name} methods`, subcommands: [...leaves, ...groups], }); } /** * Build one service's passthrough subtree from the method table. * * Multi-word Discovery resources ("users messages attachments") become * nested groups, so `gws gmail users messages get` walks like any other * tree path. */ export function apiGroups(service) { const root = new Map(); for (const m of GWS_METHODS) { if (m.service !== service) continue; let level = root; let node; for (const word of m.resource.split(' ')) { node = level.get(word); if (node === undefined) { node = { methods: [], children: new Map() }; level.set(word, node); } level = node.children; } if (node !== undefined) node.methods.push(m); } return [...root.entries()].map(([name, node]) => buildGroup(name, node)); } export function apiHandlers() { return Object.fromEntries(GWS_METHODS.map((method) => [ `${method.service} ${method.resource} ${method.method}`, new CLIHandler({ fn: (accessor, paths, texts, ctx) => runGwsMethod(method, accessor, paths, texts, ctx), write: method.http !== 'GET', }), ])); } //# sourceMappingURL=api.js.map