@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
301 lines • 12.7 kB
JavaScript
// ========= 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