@joe-broadhead/opencode-gws 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.0.8] - 2026-11-06
6
+
7
+ ### Fixed
8
+
9
+ - The `/connect` sign-in mark on an account now clears. The plugin read the mark back from
10
+ `integration.connection.active`, which returns the credential without its status, so it never saw its own report: after
11
+ Google rejected a sign-in, the account kept asking for a new one in `/gws-status`, the status RPC and `gws_status` once
12
+ it worked again. It now reads the status from `integration.get`, which projects it.
13
+
14
+ ## [0.0.7] - 2026-11-05
15
+
16
+ ### Added
17
+
18
+ - Status names any other skill that tells agents to run `gws` in a shell, such as the gws CLI's own `gws-*` skills, which lead around the plugin's plans, approvals and policy. A skill counts when a line in one of its code blocks, or an inline code span, starts with `gws ` and a word (`gws gmail +triage`); prose that only names gws does not. They appear in the status RPC's new `shellSkills`, in a **Shell gws skills** row in `/gws-status` and the dialog, and in a next step to remove each one or move it out of its skills folder.
19
+
20
+ ### Changed
21
+
22
+ - When no account here can use an admin-only service such as `admin-reports` (the `default` account leaves it out), its refusal's next step and `gws_catalog`'s `unavailable` reason now say the same thing: only a Workspace admin's account with the needed privilege can use it, so add a separate account alias for that admin with `enabledServices` including the service, not the service to an existing account. Before, the next step said to add the service to the plugin's services or to this account, which for anyone who isn't an admin ends in `ADMIN_PRIVILEGE_REQUIRED`.
23
+ - `opencode-gws` says a script creates its own session and keeps its id, never one found through history or a session list, and that an answer that only shows a preview names the tool that would commit it.
24
+
25
+ ### Fixed
26
+
27
+ - A `fields` mask whose parentheses don't balance is refused with `FIELDS_INVALID`, which names the extra `)` or the unclosed `(`, before gws runs. Google's own error for it names no argument.
28
+
5
29
  ## [0.0.6] - 2026-11-05
6
30
 
7
31
  ### Fixed
package/README.md CHANGED
@@ -114,7 +114,7 @@ Members then only run `/connect`, choose Google Workspace, and pick an access le
114
114
  | `POLICY_DENIED` | A policy rule or the admin gate refused the call. A deny is final. |
115
115
  | `RECIPIENTS_UNKNOWN` | A recipient rule applies, and the plugin could not tell who the send or share reaches. |
116
116
 
117
- A skill of your own named `opencode-gws` or `opencode-gws-recipes` (for example in `~/.claude/skills`) hides the plugin's skill of that name; `/gws-status` names it under **Hidden skills**, and the fix is to move or rename it.
117
+ A skill of your own named `opencode-gws` or `opencode-gws-recipes` (for example in `~/.claude/skills`) hides the plugin's skill of that name; `/gws-status` names it under **Hidden skills**, and the fix is to move or rename it. A skill that tells agents to run `gws` in a shell, such as the gws CLI's own `gws-*` skills, leads them around the plugin's plans and approvals; `/gws-status` names each one under **Shell gws skills**, and the fix is to remove it or move it out of its skills folder.
118
118
 
119
119
  Each OpenCode server keeps a private work folder, `$TMPDIR/opencode-gws-XXXXXX`, and removes it when it stops. A server that stopped without closing leaves its folder behind; the next start removes such folders once they are a day old and their process is gone.
120
120
 
package/dist/index.js CHANGED
@@ -6571,6 +6571,10 @@ var ErrorCode = {
6571
6571
  meaning: "The request is malformed: a schema violation, a field helpers do not take, a missing field mask on list or search, a relative path, a download on gws_read, or arguments gws rejected.",
6572
6572
  next: "Fix the request as the message says; gws_schema shows the parameters and request body."
6573
6573
  },
6574
+ FIELDS_INVALID: {
6575
+ meaning: "The fields mask's parentheses do not balance, so gws was not called.",
6576
+ next: "Give each '(' one ')' after it, or write a single path with slashes, such as body/content(paragraph/elements/textRun/content), then retry."
6577
+ },
6574
6578
  POLICY_DENIED: {
6575
6579
  meaning: "Plugin policy refused the call: a deny rule, a category the plugin never runs (deploy, watch), the service, recipients, public sharing, the admin gate, or a path outside the allowed roots. A deny is final.",
6576
6580
  next: "Do not work around it; tell the user, who can change the plugin policy."
@@ -7272,6 +7276,223 @@ function helperAdapter(id) {
7272
7276
  return ADAPTERS.get(id);
7273
7277
  }
7274
7278
 
7279
+ // src/domain/result.ts
7280
+ function isApiError(value) {
7281
+ if (Array.isArray(value)) return value.some(isApiError);
7282
+ if (!value || typeof value !== "object") return false;
7283
+ const record3 = value;
7284
+ return "error" in record3 && record3.error !== null && typeof record3.error === "object";
7285
+ }
7286
+ function parseJson(text4) {
7287
+ const lines = text4.split("\n");
7288
+ const start2 = lines.findIndex((line) => /^[[{]/.test(line.trim()));
7289
+ if (start2 < 0) return void 0;
7290
+ const candidate = lines.slice(start2).join("\n").trim();
7291
+ try {
7292
+ return JSON.parse(candidate);
7293
+ } catch {
7294
+ const parsed = [];
7295
+ for (const line of lines.slice(start2)) {
7296
+ const value = line.trim();
7297
+ if (!/^[[{]/.test(value)) continue;
7298
+ try {
7299
+ parsed.push(JSON.parse(value));
7300
+ } catch {
7301
+ }
7302
+ }
7303
+ return parsed.length > 0 ? parsed : void 0;
7304
+ }
7305
+ }
7306
+ function inlineCap(maxInlineBytes, mode, requested) {
7307
+ const base2 = mode === "full" ? Math.max(maxInlineBytes, 5 * 1024 * 1024) : maxInlineBytes;
7308
+ return requested === void 0 ? base2 : Math.min(base2, requested);
7309
+ }
7310
+ function parseResult(result, options) {
7311
+ const text4 = result.stdout.toString("utf8");
7312
+ const parsed = parseJson(text4);
7313
+ const serialized = parsed === void 0 ? text4 : JSON.stringify(parsed);
7314
+ const bytes2 = Buffer.byteLength(serialized);
7315
+ const cap = inlineCap(options.maxInlineBytes, options.mode, options.requestedInlineBytes);
7316
+ const truncated = bytes2 > cap;
7317
+ const empty = text4.trim().length === 0;
7318
+ return {
7319
+ // A successful DELETE commonly returns 204 with no body. Empty output
7320
+ // with exit 0 is success; non-JSON text is not a JSON result.
7321
+ ok: result.exitCode === 0 && (empty || parsed !== void 0 && !isApiError(parsed)),
7322
+ data: parsed,
7323
+ raw: parsed === void 0 ? text4.slice(0, cap) : void 0,
7324
+ truncated,
7325
+ diagnostics: { exitCode: result.exitCode, durationMs: result.durationMs, bytes: bytes2 }
7326
+ };
7327
+ }
7328
+ function withoutSavedBody(data, next) {
7329
+ if (!data || typeof data !== "object" || Array.isArray(data)) return data;
7330
+ const record3 = data;
7331
+ const keys = Object.keys(record3);
7332
+ if (typeof record3["saved_file"] !== "string" || typeof record3["bytes"] !== "number" || record3["status"] !== "success") return data;
7333
+ if (!keys.every((key) => ["bytes", "mimeType", "saved_file", "status"].includes(key))) return data;
7334
+ if (record3["bytes"] === 0) return "";
7335
+ return {
7336
+ ...typeof record3["mimeType"] === "string" ? { mimeType: record3["mimeType"] } : {},
7337
+ bytes: record3["bytes"],
7338
+ saved: false,
7339
+ ...next ? { next } : {}
7340
+ };
7341
+ }
7342
+ function itemKey(page2) {
7343
+ if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return void 0;
7344
+ const record3 = page2;
7345
+ if (Array.isArray(record3["items"])) return "items";
7346
+ const arrays = Object.keys(record3).filter((key) => Array.isArray(record3[key]));
7347
+ return arrays.length === 1 ? arrays[0] : void 0;
7348
+ }
7349
+ function capPageItems(data, maxItems) {
7350
+ const pages = data === void 0 ? [] : Array.isArray(data) ? data : [data];
7351
+ const keys = pages.map((page2) => itemKey(page2) ?? (isEmptyPage(page2) ? null : void 0));
7352
+ if (keys.some((key) => key === void 0)) return { data: pages, pages: pages.length, capped: false };
7353
+ const kept = [];
7354
+ let items = 0;
7355
+ let capped = false;
7356
+ for (const [index2, page2] of pages.entries()) {
7357
+ if (items >= maxItems) {
7358
+ capped = true;
7359
+ break;
7360
+ }
7361
+ const key = keys[index2];
7362
+ if (!key) {
7363
+ kept.push(page2);
7364
+ continue;
7365
+ }
7366
+ const record3 = page2;
7367
+ const list = record3[key];
7368
+ const room = maxItems - items;
7369
+ if (list.length > room) {
7370
+ const { nextPageToken: _dropped, ...rest } = record3;
7371
+ kept.push({ ...rest, [key]: list.slice(0, room) });
7372
+ items += room;
7373
+ capped = true;
7374
+ break;
7375
+ }
7376
+ kept.push(page2);
7377
+ items += list.length;
7378
+ }
7379
+ return { data: kept, pages: kept.length, items, capped };
7380
+ }
7381
+ function isEmptyPage(page2) {
7382
+ if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return false;
7383
+ return !Object.values(page2).some(Array.isArray);
7384
+ }
7385
+ function redactDryRun(value) {
7386
+ if (Array.isArray(value)) return value.map(redactDryRun);
7387
+ if (value && typeof value === "object") {
7388
+ const out = {};
7389
+ for (const [key, child] of Object.entries(value)) {
7390
+ if (/^(body|raw|content)$/i.test(key)) out[key] = "[redacted]";
7391
+ else out[key] = redactDryRun(child);
7392
+ }
7393
+ return out;
7394
+ }
7395
+ return value;
7396
+ }
7397
+ function apiErrorOf(result, parsed) {
7398
+ const data = parsed ? parsed.data : parseJson(result.stdout.toString("utf8"));
7399
+ const found = Array.isArray(data) ? data.find(isApiError) : data;
7400
+ if (!isApiError(found)) return void 0;
7401
+ return found.error;
7402
+ }
7403
+ function stderrText(result) {
7404
+ return result.stderr.toString("utf8").split("\n").filter((line) => line.trim() && !/^Using keyring backend:/i.test(line)).join("\n").trim();
7405
+ }
7406
+ var EXIT = { auth: 2, validation: 3, discovery: 4, internal: 5 };
7407
+ var SCOPE_REASONS = /* @__PURE__ */ new Set(["ACCESS_TOKEN_SCOPE_INSUFFICIENT", "insufficientPermissions"]);
7408
+ var PRIVILEGE_REFUSALS = /\bnot authori[sz]ed to (?:read|access|view|list|use|manage|perform)\b|\bcaller does not have access to the customers? reporting data\b/i;
7409
+ function privilegeRefused(result, service, parsed) {
7410
+ if (!isAdminService(service)) return false;
7411
+ if (result.timedOut || result.outputCapped || result.exitCode === 0 || result.exitCode === EXIT.auth) return false;
7412
+ const error2 = apiErrorOf(result, parsed);
7413
+ if (error2?.code !== void 0 && error2.code !== 401 && error2.code !== 403) return false;
7414
+ const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
7415
+ return !/invalid_grant/i.test(text4) && PRIVILEGE_REFUSALS.test(text4);
7416
+ }
7417
+ function isAdminService(service) {
7418
+ return service.startsWith("admin-");
7419
+ }
7420
+ var ADMIN_PRIVILEGES = { "admin-reports": "Reports" };
7421
+ function privilegeName(service) {
7422
+ const name = service ? ADMIN_PRIVILEGES[service] : void 0;
7423
+ return name ? `the ${name} privilege` : "the admin privilege it needs";
7424
+ }
7425
+ function adminServiceStep(service) {
7426
+ return `Only a Workspace admin's account with ${privilegeName(service)} can use ${service}: the user adds a separate account alias for that admin with enabledServices ["${service}"], not the service to an existing account, and signs in to it from /connect.`;
7427
+ }
7428
+ function adminAccountStep(account2, service) {
7429
+ return `add a separate account alias for a Workspace admin who has ${privilegeName(service)}, with enabledServices ["${service}"], and sign in to it from /connect, or remove ${service} from accounts.${account2}.enabledServices`;
7430
+ }
7431
+ function authProblem(result, service, parsed) {
7432
+ if (result.timedOut || result.outputCapped) return void 0;
7433
+ if (privilegeRefused(result, service, parsed)) return void 0;
7434
+ const error2 = apiErrorOf(result, parsed);
7435
+ if (result.exitCode === 0 && !error2) return void 0;
7436
+ const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
7437
+ const text4 = [error2?.message, error2?.status, stderrText(result)].filter(Boolean).join("\n");
7438
+ if (reasons.some((reason) => reason !== void 0 && SCOPE_REASONS.has(reason)) || /insufficient authentication scopes|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(text4)) {
7439
+ return "AUTH_SCOPE_MISSING";
7440
+ }
7441
+ if (error2?.code === 401 || error2?.status === "UNAUTHENTICATED" || reasons.includes("authError") || /invalid_grant|\bUNAUTHENTICATED\b|^error\[auth\]/im.test(text4)) {
7442
+ return "AUTH_REQUIRED";
7443
+ }
7444
+ return void 0;
7445
+ }
7446
+ var GENERIC_REMEDIATION = {
7447
+ signIn: "sign in to the account again",
7448
+ consent: "sign in again and grant the scope this operation needs",
7449
+ privilege: "add a separate account alias for a Workspace admin who has the admin privilege this operation needs, and sign in to it, or remove the service from enabledServices"
7450
+ };
7451
+ var DISABLED_REASONS = /* @__PURE__ */ new Set(["accessNotConfigured", "SERVICE_DISABLED"]);
7452
+ var CONSOLE_URL = /https:\/\/console\.(?:developers|cloud)\.google\.com\/[^\s"'<>)]+/;
7453
+ function disabledApi(result, parsed) {
7454
+ const error2 = apiErrorOf(result, parsed);
7455
+ const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
7456
+ const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
7457
+ if (!reasons.some((reason) => reason !== void 0 && DISABLED_REASONS.has(reason)) && !/accessnotconfigured|has not been used|is disabled/i.test(text4)) {
7458
+ return void 0;
7459
+ }
7460
+ const enableUrl = (typeof error2?.enable_url === "string" && CONSOLE_URL.test(error2.enable_url) ? error2.enable_url : CONSOLE_URL.exec(text4)?.[0])?.replace(/[.,;]+$/, "");
7461
+ const project = (enableUrl ? /[?&]project=([\w.:-]+)/.exec(enableUrl)?.[1] : void 0) ?? /\bin project ([\w.:-]+)/.exec(text4)?.[1];
7462
+ return { ...enableUrl ? { enableUrl } : {}, ...project ? { project } : {} };
7463
+ }
7464
+ function processError(result, parsed, service, remediation = GENERIC_REMEDIATION) {
7465
+ if (result.timedOut) return new GwsError("TIMEOUT", `gws did not finish within the configured execution.timeoutMs (${result.durationMs}ms elapsed)`);
7466
+ if (result.outputCapped) return new GwsError("OUTPUT_LIMIT", "gws output exceeded execution.maxStdoutBytes/maxStderrBytes; narrow fields or pagination");
7467
+ const stderr = stderrText(result);
7468
+ const payload = apiErrorOf(result, parsed);
7469
+ const message = payload?.message || stderr || "gws command failed";
7470
+ if (privilegeRefused(result, service, parsed)) {
7471
+ return new GwsError(
7472
+ "ADMIN_PRIVILEGE_REQUIRED",
7473
+ `Google accepted the sign-in but refused the call, because the account lacks a Workspace admin privilege; signing in again as the same account cannot help (gws: ${message.slice(0, 300)})`,
7474
+ { next: `${remediation.privilege.charAt(0).toUpperCase()}${remediation.privilege.slice(1)}.` }
7475
+ );
7476
+ }
7477
+ const problem = authProblem(result, service, parsed) ?? (result.exitCode === EXIT.auth ? "AUTH_REQUIRED" : void 0);
7478
+ if (problem === "AUTH_SCOPE_MISSING") {
7479
+ return new GwsError("AUTH_SCOPE_MISSING", `the credential lacks a scope for this operation; ${remediation.consent} (gws: ${message.slice(0, 300)})`);
7480
+ }
7481
+ if (problem === "AUTH_REQUIRED") {
7482
+ return new GwsError("AUTH_REQUIRED", `the Google Workspace sign-in is missing or no longer valid; ${remediation.signIn} (gws: ${message.slice(0, 300)})`);
7483
+ }
7484
+ const disabled = disabledApi(result, parsed);
7485
+ if (disabled) {
7486
+ const where = disabled.project ? ` for project ${disabled.project}` : "";
7487
+ const next = disabled.enableUrl ? `Ask the user to open ${disabled.enableUrl} and enable the API${where}, then wait about a minute and retry.` : `Ask the user to enable the API${where} in the Google Cloud console, then wait about a minute and retry.`;
7488
+ return new GwsError("API_DISABLED", message.slice(0, 500), { next, ...disabled.enableUrl ? { enableUrl: disabled.enableUrl } : {} });
7489
+ }
7490
+ if (result.exitCode === EXIT.validation) return new GwsError("GWS_VALIDATION_ERROR", `gws rejected the request: ${message.slice(0, 500)}`);
7491
+ if (result.exitCode === EXIT.discovery) return new GwsError("GWS_DISCOVERY_ERROR", `gws could not load the API description: ${message.slice(0, 500)}`);
7492
+ if (result.exitCode === EXIT.internal) return new GwsError("GWS_INTERNAL_ERROR", `gws failed: ${message.slice(0, 500)}`);
7493
+ return new GwsError("GWS_API_ERROR", message.slice(0, 500));
7494
+ }
7495
+
7275
7496
  // src/domain/manifest.ts
7276
7497
  var ROOT = join(dirname(fileURLToPath(new URL("./domain/manifest.js", import.meta.url).href)), "..", "..");
7277
7498
  var SERVICE_ALIASES = {
@@ -7413,9 +7634,7 @@ function unavailableReason(service) {
7413
7634
  if (DELEGATION_SERVICES.includes(service)) {
7414
7635
  return `Needs domain-wide delegation, which a Workspace admin sets up; then the user adds ${service} to accounts.<alias>.enabledServices and its scopes to accounts.<alias>.scopes.`;
7415
7636
  }
7416
- if (service.startsWith("admin-")) {
7417
- return `Needs a Workspace admin with the privilege it reads; the user adds a separate account alias for that admin with enabledServices ["${service}"].`;
7418
- }
7637
+ if (isAdminService(service)) return adminServiceStep(service);
7419
7638
  return `Not enabled for any account; the user can add ${service} to accounts.<alias>.enabledServices.`;
7420
7639
  }
7421
7640
  function unavailableServices(tokens, only, allowed, weak) {
@@ -7959,7 +8178,7 @@ function checkHost(version2, range, allowUntested) {
7959
8178
  }
7960
8179
 
7961
8180
  // src/domain/version.ts
7962
- var PLUGIN_VERSION = "0.0.6";
8181
+ var PLUGIN_VERSION = "0.0.8";
7963
8182
  var PLUGIN_ID = "google-workspace.gws";
7964
8183
 
7965
8184
  // src/host/agents.ts
@@ -8065,6 +8284,7 @@ var status = object2(
8065
8284
  accounts: { type: "array", items: account },
8066
8285
  auditDegraded: { type: "boolean" },
8067
8286
  shadowedSkills: { type: "array", items: object2({ id: text, path: text }, ["id", "path"]) },
8287
+ shellSkills: { type: "array", items: object2({ id: text, path: text }, ["id", "path"]) },
8068
8288
  unguardedAgents: texts,
8069
8289
  permissionsBlock: text,
8070
8290
  nextStep: text
@@ -8094,6 +8314,10 @@ var SHADOWED_LABEL = "Hidden skills";
8094
8314
  function shadowedText(status2) {
8095
8315
  return (status2.shadowedSkills ?? []).map((skill) => `${skill.id} (hidden by ${skill.path})`).join(", ");
8096
8316
  }
8317
+ var SHELL_LABEL = "Shell gws skills";
8318
+ function shellText(status2) {
8319
+ return (status2.shellSkills ?? []).map((skill) => `${skill.id} (${skill.path})`).join(", ");
8320
+ }
8097
8321
  var DELEGATION_LABEL = "Needs domain-wide delegation";
8098
8322
  function delegationText(account2) {
8099
8323
  const services = account2.delegationRequired ?? [];
@@ -8293,220 +8517,6 @@ var PlanStore = class {
8293
8517
  }
8294
8518
  };
8295
8519
 
8296
- // src/domain/result.ts
8297
- function isApiError(value) {
8298
- if (Array.isArray(value)) return value.some(isApiError);
8299
- if (!value || typeof value !== "object") return false;
8300
- const record3 = value;
8301
- return "error" in record3 && record3.error !== null && typeof record3.error === "object";
8302
- }
8303
- function parseJson(text4) {
8304
- const lines = text4.split("\n");
8305
- const start2 = lines.findIndex((line) => /^[[{]/.test(line.trim()));
8306
- if (start2 < 0) return void 0;
8307
- const candidate = lines.slice(start2).join("\n").trim();
8308
- try {
8309
- return JSON.parse(candidate);
8310
- } catch {
8311
- const parsed = [];
8312
- for (const line of lines.slice(start2)) {
8313
- const value = line.trim();
8314
- if (!/^[[{]/.test(value)) continue;
8315
- try {
8316
- parsed.push(JSON.parse(value));
8317
- } catch {
8318
- }
8319
- }
8320
- return parsed.length > 0 ? parsed : void 0;
8321
- }
8322
- }
8323
- function inlineCap(maxInlineBytes, mode, requested) {
8324
- const base2 = mode === "full" ? Math.max(maxInlineBytes, 5 * 1024 * 1024) : maxInlineBytes;
8325
- return requested === void 0 ? base2 : Math.min(base2, requested);
8326
- }
8327
- function parseResult(result, options) {
8328
- const text4 = result.stdout.toString("utf8");
8329
- const parsed = parseJson(text4);
8330
- const serialized = parsed === void 0 ? text4 : JSON.stringify(parsed);
8331
- const bytes2 = Buffer.byteLength(serialized);
8332
- const cap = inlineCap(options.maxInlineBytes, options.mode, options.requestedInlineBytes);
8333
- const truncated = bytes2 > cap;
8334
- const empty = text4.trim().length === 0;
8335
- return {
8336
- // A successful DELETE commonly returns 204 with no body. Empty output
8337
- // with exit 0 is success; non-JSON text is not a JSON result.
8338
- ok: result.exitCode === 0 && (empty || parsed !== void 0 && !isApiError(parsed)),
8339
- data: parsed,
8340
- raw: parsed === void 0 ? text4.slice(0, cap) : void 0,
8341
- truncated,
8342
- diagnostics: { exitCode: result.exitCode, durationMs: result.durationMs, bytes: bytes2 }
8343
- };
8344
- }
8345
- function withoutSavedBody(data, next) {
8346
- if (!data || typeof data !== "object" || Array.isArray(data)) return data;
8347
- const record3 = data;
8348
- const keys = Object.keys(record3);
8349
- if (typeof record3["saved_file"] !== "string" || typeof record3["bytes"] !== "number" || record3["status"] !== "success") return data;
8350
- if (!keys.every((key) => ["bytes", "mimeType", "saved_file", "status"].includes(key))) return data;
8351
- if (record3["bytes"] === 0) return "";
8352
- return {
8353
- ...typeof record3["mimeType"] === "string" ? { mimeType: record3["mimeType"] } : {},
8354
- bytes: record3["bytes"],
8355
- saved: false,
8356
- ...next ? { next } : {}
8357
- };
8358
- }
8359
- function itemKey(page2) {
8360
- if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return void 0;
8361
- const record3 = page2;
8362
- if (Array.isArray(record3["items"])) return "items";
8363
- const arrays = Object.keys(record3).filter((key) => Array.isArray(record3[key]));
8364
- return arrays.length === 1 ? arrays[0] : void 0;
8365
- }
8366
- function capPageItems(data, maxItems) {
8367
- const pages = data === void 0 ? [] : Array.isArray(data) ? data : [data];
8368
- const keys = pages.map((page2) => itemKey(page2) ?? (isEmptyPage(page2) ? null : void 0));
8369
- if (keys.some((key) => key === void 0)) return { data: pages, pages: pages.length, capped: false };
8370
- const kept = [];
8371
- let items = 0;
8372
- let capped = false;
8373
- for (const [index2, page2] of pages.entries()) {
8374
- if (items >= maxItems) {
8375
- capped = true;
8376
- break;
8377
- }
8378
- const key = keys[index2];
8379
- if (!key) {
8380
- kept.push(page2);
8381
- continue;
8382
- }
8383
- const record3 = page2;
8384
- const list = record3[key];
8385
- const room = maxItems - items;
8386
- if (list.length > room) {
8387
- const { nextPageToken: _dropped, ...rest } = record3;
8388
- kept.push({ ...rest, [key]: list.slice(0, room) });
8389
- items += room;
8390
- capped = true;
8391
- break;
8392
- }
8393
- kept.push(page2);
8394
- items += list.length;
8395
- }
8396
- return { data: kept, pages: kept.length, items, capped };
8397
- }
8398
- function isEmptyPage(page2) {
8399
- if (!page2 || typeof page2 !== "object" || Array.isArray(page2)) return false;
8400
- return !Object.values(page2).some(Array.isArray);
8401
- }
8402
- function redactDryRun(value) {
8403
- if (Array.isArray(value)) return value.map(redactDryRun);
8404
- if (value && typeof value === "object") {
8405
- const out = {};
8406
- for (const [key, child] of Object.entries(value)) {
8407
- if (/^(body|raw|content)$/i.test(key)) out[key] = "[redacted]";
8408
- else out[key] = redactDryRun(child);
8409
- }
8410
- return out;
8411
- }
8412
- return value;
8413
- }
8414
- function apiErrorOf(result, parsed) {
8415
- const data = parsed ? parsed.data : parseJson(result.stdout.toString("utf8"));
8416
- const found = Array.isArray(data) ? data.find(isApiError) : data;
8417
- if (!isApiError(found)) return void 0;
8418
- return found.error;
8419
- }
8420
- function stderrText(result) {
8421
- return result.stderr.toString("utf8").split("\n").filter((line) => line.trim() && !/^Using keyring backend:/i.test(line)).join("\n").trim();
8422
- }
8423
- var EXIT = { auth: 2, validation: 3, discovery: 4, internal: 5 };
8424
- var SCOPE_REASONS = /* @__PURE__ */ new Set(["ACCESS_TOKEN_SCOPE_INSUFFICIENT", "insufficientPermissions"]);
8425
- var PRIVILEGE_REFUSALS = /\bnot authori[sz]ed to (?:read|access|view|list|use|manage|perform)\b|\bcaller does not have access to the customers? reporting data\b/i;
8426
- function privilegeRefused(result, service, parsed) {
8427
- if (!isAdminService(service)) return false;
8428
- if (result.timedOut || result.outputCapped || result.exitCode === 0 || result.exitCode === EXIT.auth) return false;
8429
- const error2 = apiErrorOf(result, parsed);
8430
- if (error2?.code !== void 0 && error2.code !== 401 && error2.code !== 403) return false;
8431
- const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
8432
- return !/invalid_grant/i.test(text4) && PRIVILEGE_REFUSALS.test(text4);
8433
- }
8434
- function isAdminService(service) {
8435
- return service.startsWith("admin-");
8436
- }
8437
- var ADMIN_PRIVILEGES = { "admin-reports": "Reports" };
8438
- function privilegeName(service) {
8439
- const name = service ? ADMIN_PRIVILEGES[service] : void 0;
8440
- return name ? `the ${name} privilege` : "the admin privilege it needs";
8441
- }
8442
- function adminAccountStep(account2, service) {
8443
- return `add a separate account alias for a Workspace admin who has ${privilegeName(service)}, with enabledServices ["${service}"], and sign in to it from /connect, or remove ${service} from accounts.${account2}.enabledServices`;
8444
- }
8445
- function authProblem(result, service, parsed) {
8446
- if (result.timedOut || result.outputCapped) return void 0;
8447
- if (privilegeRefused(result, service, parsed)) return void 0;
8448
- const error2 = apiErrorOf(result, parsed);
8449
- if (result.exitCode === 0 && !error2) return void 0;
8450
- const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
8451
- const text4 = [error2?.message, error2?.status, stderrText(result)].filter(Boolean).join("\n");
8452
- if (reasons.some((reason) => reason !== void 0 && SCOPE_REASONS.has(reason)) || /insufficient authentication scopes|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(text4)) {
8453
- return "AUTH_SCOPE_MISSING";
8454
- }
8455
- if (error2?.code === 401 || error2?.status === "UNAUTHENTICATED" || reasons.includes("authError") || /invalid_grant|\bUNAUTHENTICATED\b|^error\[auth\]/im.test(text4)) {
8456
- return "AUTH_REQUIRED";
8457
- }
8458
- return void 0;
8459
- }
8460
- var GENERIC_REMEDIATION = {
8461
- signIn: "sign in to the account again",
8462
- consent: "sign in again and grant the scope this operation needs",
8463
- privilege: "add a separate account alias for a Workspace admin who has the admin privilege this operation needs, and sign in to it, or remove the service from enabledServices"
8464
- };
8465
- var DISABLED_REASONS = /* @__PURE__ */ new Set(["accessNotConfigured", "SERVICE_DISABLED"]);
8466
- var CONSOLE_URL = /https:\/\/console\.(?:developers|cloud)\.google\.com\/[^\s"'<>)]+/;
8467
- function disabledApi(result, parsed) {
8468
- const error2 = apiErrorOf(result, parsed);
8469
- const reasons = [error2?.reason, ...(error2?.errors ?? []).map((item) => item?.reason), ...(error2?.details ?? []).map((item) => item?.reason)];
8470
- const text4 = [error2?.message, stderrText(result)].filter(Boolean).join("\n");
8471
- if (!reasons.some((reason) => reason !== void 0 && DISABLED_REASONS.has(reason)) && !/accessnotconfigured|has not been used|is disabled/i.test(text4)) {
8472
- return void 0;
8473
- }
8474
- const enableUrl = (typeof error2?.enable_url === "string" && CONSOLE_URL.test(error2.enable_url) ? error2.enable_url : CONSOLE_URL.exec(text4)?.[0])?.replace(/[.,;]+$/, "");
8475
- const project = (enableUrl ? /[?&]project=([\w.:-]+)/.exec(enableUrl)?.[1] : void 0) ?? /\bin project ([\w.:-]+)/.exec(text4)?.[1];
8476
- return { ...enableUrl ? { enableUrl } : {}, ...project ? { project } : {} };
8477
- }
8478
- function processError(result, parsed, service, remediation = GENERIC_REMEDIATION) {
8479
- if (result.timedOut) return new GwsError("TIMEOUT", `gws did not finish within the configured execution.timeoutMs (${result.durationMs}ms elapsed)`);
8480
- if (result.outputCapped) return new GwsError("OUTPUT_LIMIT", "gws output exceeded execution.maxStdoutBytes/maxStderrBytes; narrow fields or pagination");
8481
- const stderr = stderrText(result);
8482
- const payload = apiErrorOf(result, parsed);
8483
- const message = payload?.message || stderr || "gws command failed";
8484
- if (privilegeRefused(result, service, parsed)) {
8485
- return new GwsError(
8486
- "ADMIN_PRIVILEGE_REQUIRED",
8487
- `Google accepted the sign-in but refused the call, because the account lacks a Workspace admin privilege; signing in again as the same account cannot help (gws: ${message.slice(0, 300)})`,
8488
- { next: `${remediation.privilege.charAt(0).toUpperCase()}${remediation.privilege.slice(1)}.` }
8489
- );
8490
- }
8491
- const problem = authProblem(result, service, parsed) ?? (result.exitCode === EXIT.auth ? "AUTH_REQUIRED" : void 0);
8492
- if (problem === "AUTH_SCOPE_MISSING") {
8493
- return new GwsError("AUTH_SCOPE_MISSING", `the credential lacks a scope for this operation; ${remediation.consent} (gws: ${message.slice(0, 300)})`);
8494
- }
8495
- if (problem === "AUTH_REQUIRED") {
8496
- return new GwsError("AUTH_REQUIRED", `the Google Workspace sign-in is missing or no longer valid; ${remediation.signIn} (gws: ${message.slice(0, 300)})`);
8497
- }
8498
- const disabled = disabledApi(result, parsed);
8499
- if (disabled) {
8500
- const where = disabled.project ? ` for project ${disabled.project}` : "";
8501
- const next = disabled.enableUrl ? `Ask the user to open ${disabled.enableUrl} and enable the API${where}, then wait about a minute and retry.` : `Ask the user to enable the API${where} in the Google Cloud console, then wait about a minute and retry.`;
8502
- return new GwsError("API_DISABLED", message.slice(0, 500), { next, ...disabled.enableUrl ? { enableUrl: disabled.enableUrl } : {} });
8503
- }
8504
- if (result.exitCode === EXIT.validation) return new GwsError("GWS_VALIDATION_ERROR", `gws rejected the request: ${message.slice(0, 500)}`);
8505
- if (result.exitCode === EXIT.discovery) return new GwsError("GWS_DISCOVERY_ERROR", `gws could not load the API description: ${message.slice(0, 500)}`);
8506
- if (result.exitCode === EXIT.internal) return new GwsError("GWS_INTERNAL_ERROR", `gws failed: ${message.slice(0, 500)}`);
8507
- return new GwsError("GWS_API_ERROR", message.slice(0, 500));
8508
- }
8509
-
8510
8520
  // src/host/commands.ts
8511
8521
  var COMMANDS = {
8512
8522
  status: { name: "gws-status", description: "Google Workspace: sign-in, access, pending plans, setup steps, and unguarded agents" },
@@ -8574,6 +8584,7 @@ function formatStatus(status2, plans = [], now = Date.now()) {
8574
8584
  if (setup.length > 0) lines.push(...setup, "");
8575
8585
  lines.push(...planLines(plans, now), "");
8576
8586
  if (status2.shadowedSkills?.length) lines.push(`${SHADOWED_LABEL}: ${shadowedText(status2)}`);
8587
+ if (status2.shellSkills?.length) lines.push(`${SHELL_LABEL}: ${shellText(status2)}`);
8577
8588
  lines.push(`Tools: gws tools for ${status2.services.join(", ") || "no services"} (${status2.gws.version})`);
8578
8589
  if (status2.unguardedAgents.length === 0) {
8579
8590
  lines.push("Agents without default rules: none");
@@ -9443,6 +9454,15 @@ function integrationTokenProvider(ctx, config2, ids) {
9443
9454
  return { access: credential.access, grantedScopes: signIn.grantedScopes };
9444
9455
  };
9445
9456
  }
9457
+ async function showsNeedsAuth(ctx, integrationID, connectionID) {
9458
+ try {
9459
+ const { data } = await ctx.integration.get({ integrationID });
9460
+ const shown = data.connections?.find((item) => item.type === "credential" && item.id === connectionID);
9461
+ return shown?.type === "credential" && shown.status?.status === "needs_auth";
9462
+ } catch {
9463
+ return false;
9464
+ }
9465
+ }
9446
9466
  function integrationConnection(ctx, ids) {
9447
9467
  const lifetime = new Lifecycle();
9448
9468
  return async (account2, signal) => {
@@ -9450,7 +9470,7 @@ function integrationConnection(ctx, ids) {
9450
9470
  if (!id) return { connected: false, needsAuth: false };
9451
9471
  const connection = await lifetime.wait(() => ctx.integration.connection.active(id), signal);
9452
9472
  if (!connection) return { connected: false, needsAuth: false };
9453
- const needsAuth = connection.status?.status === "needs_auth";
9473
+ const needsAuth = connection.type === "credential" && await lifetime.wait(() => showsNeedsAuth(ctx, id, connection.id), signal);
9454
9474
  let credential;
9455
9475
  try {
9456
9476
  credential = await lifetime.wait(() => ctx.integration.connection.resolve(connection), signal);
@@ -9510,6 +9530,20 @@ function ownSkillPath(id) {
9510
9530
  function shadowedSkills(listed) {
9511
9531
  return listed.filter((skill) => SKILL_IDS.includes(skill.id) && skill.path !== ownSkillPath(skill.id)).map((skill) => ({ id: skill.id, path: skill.path }));
9512
9532
  }
9533
+ var SHELL_LINE = /^\s*(?:\$\s+)?gws\s+\S/;
9534
+ var SHELL_SPAN = /`gws\s+[^`\s][^`]*`/;
9535
+ function shellSkills(listed) {
9536
+ return listed.filter((skill) => skill.path !== ownSkillPath(skill.id) && teachesShell(skill.content ?? "")).map((skill) => ({ id: skill.id, path: skill.path }));
9537
+ }
9538
+ function teachesShell(content) {
9539
+ if (SHELL_SPAN.test(content)) return true;
9540
+ let fenced = false;
9541
+ for (const line of content.split(/\r?\n/)) {
9542
+ if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
9543
+ else if (fenced && SHELL_LINE.test(line)) return true;
9544
+ }
9545
+ return false;
9546
+ }
9513
9547
  function skillLister(ctx) {
9514
9548
  return async () => {
9515
9549
  const directory = ctx.location?.directory;
@@ -9548,11 +9582,14 @@ async function registerSkills(ctx, config2) {
9548
9582
  }
9549
9583
 
9550
9584
  // src/host/rpc.ts
9551
- function nextStep(accounts, shadowed, agents) {
9585
+ function nextStep(accounts, shadowed, shell, agents) {
9552
9586
  const account2 = accounts.find((entry) => entry.nextStep);
9553
9587
  if (account2?.nextStep) return accounts.length > 1 ? `${account2.alias}: ${account2.nextStep}` : account2.nextStep;
9554
9588
  const [skill] = shadowed;
9555
9589
  if (skill) return `Move or rename the skill at ${skill.path}: its id ${skill.id} hides the plugin's own skill.`;
9590
+ const why = "run gws in a shell, which skips plans, approvals and policy.";
9591
+ if (shell.length === 1) return `Remove the skill at ${shell[0].path}, or move it out of its skills folder: it tells agents to ${why}`;
9592
+ if (shell.length > 1) return `Remove the ${shell.length} skills listed under ${SHELL_LABEL}, or move them out of their skills folders: they tell agents to ${why}`;
9556
9593
  if (agents.length > 0) return UNGUARDED_NEXT_STEP;
9557
9594
  return void 0;
9558
9595
  }
@@ -9565,8 +9602,10 @@ function statusSource(runtime, listAgents, listSkills = async () => []) {
9565
9602
  return async (signal) => {
9566
9603
  const base2 = await runtime.status(signal);
9567
9604
  const agents = unguardedAgents(await runtime.wait(() => listAgents(), signal));
9568
- const shadowed = shadowedSkills(await runtime.wait(() => listSkills(), signal));
9569
- const next = nextStep(base2.accounts, shadowed, agents);
9605
+ const skills = await runtime.wait(() => listSkills(), signal);
9606
+ const shadowed = shadowedSkills(skills);
9607
+ const shell = shellSkills(skills);
9608
+ const next = nextStep(base2.accounts, shadowed, shell, agents);
9570
9609
  return {
9571
9610
  version: base2.version,
9572
9611
  host: base2.host,
@@ -9576,6 +9615,7 @@ function statusSource(runtime, listAgents, listSkills = async () => []) {
9576
9615
  accounts: base2.accounts,
9577
9616
  auditDegraded: base2.auditDegraded,
9578
9617
  ...shadowed.length > 0 ? { shadowedSkills: shadowed } : {},
9618
+ ...shell.length > 0 ? { shellSkills: shell } : {},
9579
9619
  unguardedAgents: agents,
9580
9620
  ...agents.length > 0 ? { permissionsBlock: permissionsBlock(agents) } : {},
9581
9621
  nextStep: next ?? NOTHING_TO_DO
@@ -9867,7 +9907,7 @@ var COMMIT_CATEGORIES2 = [
9867
9907
  { category: "admin", description: "Commits one admin plan from gws_plan. Refused unless the plugin's admin setting allows the operation." }
9868
9908
  ];
9869
9909
  async function compactStatus(status2, signal) {
9870
- const { unguardedAgents: unguardedAgents2, permissionsBlock: _block, shadowedSkills: _shadowed, auditDegraded, ...rest } = await status2(signal);
9910
+ const { unguardedAgents: unguardedAgents2, permissionsBlock: _block, shadowedSkills: _shadowed, shellSkills: _shell, auditDegraded, ...rest } = await status2(signal);
9871
9911
  const count = unguardedAgents2.length;
9872
9912
  return {
9873
9913
  ...rest,
@@ -11460,6 +11500,7 @@ function buildCommand(entry, request, config2, options) {
11460
11500
  { next: "Retry with fields naming only what the user needs; gws_schema shows the response fields." }
11461
11501
  );
11462
11502
  }
11503
+ if (typeof params["fields"] === "string") checkMask(params["fields"]);
11463
11504
  if (request.upload && entry.httpMethod === "GET") {
11464
11505
  throw new GwsError("GWS_VALIDATION_ERROR", `${entry.id} is a read and cannot upload media`);
11465
11506
  }
@@ -11517,6 +11558,14 @@ function buildCommand(entry, request, config2, options) {
11517
11558
  }
11518
11559
  };
11519
11560
  }
11561
+ function checkMask(mask) {
11562
+ let open3 = 0;
11563
+ for (const [index2, char] of [...mask].entries()) {
11564
+ if (char === "(") open3 += 1;
11565
+ else if (char === ")" && --open3 < 0) throw new GwsError("FIELDS_INVALID", `'${mask}' has an extra ')' at character ${index2 + 1}`);
11566
+ }
11567
+ if (open3 > 0) throw new GwsError("FIELDS_INVALID", `'${mask}' leaves ${open3} '(' unclosed`);
11568
+ }
11520
11569
  function redactParams(params) {
11521
11570
  const out = {};
11522
11571
  for (const [key, value] of Object.entries(params)) {
@@ -11896,8 +11945,11 @@ var METHOD_FIELDS = ["params", "body", "fields", "pagination", "upload", "downlo
11896
11945
  var PLAN_INSTEAD = "Call gws_plan with the same request, then commit with the tool named by the plan's category.";
11897
11946
  var SAVE_INSTEAD = "Google answered with a body that is not JSON. To save it, plan the same request with download.path, then commit it with gws_export.";
11898
11947
  var ADDRESS_TTL_MS = 10 * 60 * 1e3;
11899
- function policyError(decision, account2) {
11948
+ function policyError(decision, account2, service) {
11900
11949
  const detail = decision.violations.join("; ");
11950
+ if (service && isAdminService(service) && decision.violations.includes("service-disabled")) {
11951
+ return new GwsError(decision.code, detail, { next: adminServiceStep(service) });
11952
+ }
11901
11953
  if (decision.code === "ACCOUNT_DOMAIN_UNKNOWN") {
11902
11954
  return new GwsError("ACCOUNT_DOMAIN_UNKNOWN", detail, {
11903
11955
  next: `Ask the user to set accounts.${account2}.domains to the account's email domains, or to enable Gmail, Drive or Calendar for '${account2}' so the plugin can read its address.`
@@ -11935,7 +11987,7 @@ var Operations = class {
11935
11987
  visible(service) {
11936
11988
  if (!this.runtime.isVisible(service)) {
11937
11989
  throw new GwsError("POLICY_DENIED", `service '${service}' is not in the model-facing services list`, {
11938
- next: "Tell the user; they can add the service to the plugin's services option."
11990
+ next: isAdminService(service) ? adminServiceStep(service) : "Tell the user; they can add the service to the plugin's services option."
11939
11991
  });
11940
11992
  }
11941
11993
  }
@@ -11988,7 +12040,7 @@ var Operations = class {
11988
12040
  }
11989
12041
  const facts = { recipients: recipientPlan(entry.id, "read", request).known, accountDomains: this.runtime.config.accounts[request.account]?.domains };
11990
12042
  const decision2 = evaluatePolicy(this.runtime.config, entry, "read", request, facts);
11991
- if (decision2.effect === "deny") throw policyError(decision2, request.account);
12043
+ if (decision2.effect === "deny") throw policyError(decision2, request.account, entry.service);
11992
12044
  return { category: "read", decision: decision2 };
11993
12045
  }
11994
12046
  const category = helper ? helperCategory(helper, request.input) : effectiveCategory(entry, request);
@@ -11997,7 +12049,7 @@ var Operations = class {
11997
12049
  throw new GwsError("POLICY_DENIED", `'${entry.id}' is ${category} and cannot be planned for commit`, { next: "Tell the user the plugin never runs it." });
11998
12050
  }
11999
12051
  const decision = evaluatePolicy(this.runtime.config, entry, category, request, "pending");
12000
- if (decision.effect === "deny") throw policyError(decision, request.account);
12052
+ if (decision.effect === "deny") throw policyError(decision, request.account, entry.service);
12001
12053
  if (category === "admin" && (!this.runtime.config.admin.enabled || decision.effect !== "allow")) {
12002
12054
  throw new GwsError("POLICY_DENIED", "admin operations require admin.enabled, an allowed service, and a reason when one is required");
12003
12055
  }
@@ -12031,7 +12083,7 @@ var Operations = class {
12031
12083
  const target = "method" in found ? { account: account2, operation: found.method.id, service: found.method.service, entry: found.method } : { account: account2, operation: found.helper.id, service: found.helper.service, helper: found.helper };
12032
12084
  if (!profile.enabledServices.includes(target.service)) {
12033
12085
  throw new GwsError("POLICY_DENIED", `service '${target.service}' is not enabled for '${account2}'`, {
12034
- next: `Use an account that enables ${target.service}, or ask the user to add it to accounts.${account2}.enabledServices.`
12086
+ next: isAdminService(target.service) ? adminServiceStep(target.service) : `Use an account that enables ${target.service}, or ask the user to add it to accounts.${account2}.enabledServices.`
12035
12087
  });
12036
12088
  }
12037
12089
  this.visible(target.service);
@@ -12109,7 +12161,7 @@ var Operations = class {
12109
12161
  const recipientsPlan = recipientPlan(entry.id, category, request);
12110
12162
  const facts = await this.policyFacts(resolved, category, recipientsPlan, ctx);
12111
12163
  const decision = evaluatePolicy(this.runtime.config, entry, category, request, facts);
12112
- if (decision.effect === "deny") throw policyError(decision, request.account);
12164
+ if (decision.effect === "deny") throw policyError(decision, request.account, entry.service);
12113
12165
  const limits = fileLimits(decision);
12114
12166
  let dryRunArgv;
12115
12167
  let commitArgv;
package/dist/tui.js CHANGED
@@ -47,6 +47,7 @@ var status = object(
47
47
  accounts: { type: "array", items: account },
48
48
  auditDegraded: { type: "boolean" },
49
49
  shadowedSkills: { type: "array", items: object({ id: text, path: text }, ["id", "path"]) },
50
+ shellSkills: { type: "array", items: object({ id: text, path: text }, ["id", "path"]) },
50
51
  unguardedAgents: texts,
51
52
  permissionsBlock: text,
52
53
  nextStep: text
@@ -67,11 +68,12 @@ var isRecord = (value) => typeof value === "object" && value !== null && !Array.
67
68
  var isStrings = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
68
69
  var optional = (value, check) => value === void 0 || check(value);
69
70
  var isText = (value) => typeof value === "string";
71
+ var isSkills = (value) => Array.isArray(value) && value.every((skill) => isRecord(skill) && isText(skill["id"]) && isText(skill["path"]));
70
72
  function isAccount(value) {
71
73
  return isRecord(value) && isText(value["alias"]) && MODES.includes(value["mode"]) && isStrings(value["services"]) && typeof value["connected"] === "boolean" && optional(value["integrationID"], isText) && optional(value["needsAuth"], (entry) => entry === true) && optional(value["access"], (entry) => ACCESS.includes(entry)) && optional(value["clientSource"], (entry) => CLIENT_SOURCES.includes(entry)) && optional(value["grantedScopes"], isStrings) && optional(value["missingScopes"], isStrings) && optional(value["delegationRequired"], isStrings) && optional(value["nextStep"], isText);
72
74
  }
73
75
  function isStatus(value) {
74
- return isRecord(value) && isText(value["version"]) && isRecord(value["host"]) && (value["host"]["version"] === null || isText(value["host"]["version"])) && isText(value["host"]["range"]) && typeof value["host"]["tested"] === "boolean" && STATES.includes(value["state"]) && isRecord(value["gws"]) && isText(value["gws"]["path"]) && isText(value["gws"]["version"]) && isStrings(value["services"]) && Array.isArray(value["accounts"]) && value["accounts"].every(isAccount) && optional(value["auditDegraded"], (entry) => typeof entry === "boolean") && optional(value["shadowedSkills"], (entry) => Array.isArray(entry) && entry.every((skill) => isRecord(skill) && isText(skill["id"]) && isText(skill["path"]))) && isStrings(value["unguardedAgents"]) && optional(value["permissionsBlock"], isText) && isText(value["nextStep"]);
76
+ return isRecord(value) && isText(value["version"]) && isRecord(value["host"]) && (value["host"]["version"] === null || isText(value["host"]["version"])) && isText(value["host"]["range"]) && typeof value["host"]["tested"] === "boolean" && STATES.includes(value["state"]) && isRecord(value["gws"]) && isText(value["gws"]["path"]) && isText(value["gws"]["version"]) && isStrings(value["services"]) && Array.isArray(value["accounts"]) && value["accounts"].every(isAccount) && optional(value["auditDegraded"], (entry) => typeof entry === "boolean") && optional(value["shadowedSkills"], isSkills) && optional(value["shellSkills"], isSkills) && isStrings(value["unguardedAgents"]) && optional(value["permissionsBlock"], isText) && isText(value["nextStep"]);
75
77
  }
76
78
  function connectionLabel(account2) {
77
79
  if (account2.connected) return "connected";
@@ -85,6 +87,10 @@ var SHADOWED_LABEL = "Hidden skills";
85
87
  function shadowedText(status2) {
86
88
  return (status2.shadowedSkills ?? []).map((skill) => `${skill.id} (hidden by ${skill.path})`).join(", ");
87
89
  }
90
+ var SHELL_LABEL = "Shell gws skills";
91
+ function shellText(status2) {
92
+ return (status2.shellSkills ?? []).map((skill) => `${skill.id} (${skill.path})`).join(", ");
93
+ }
88
94
  var DELEGATION_LABEL = "Needs domain-wide delegation";
89
95
  function delegationText(account2) {
90
96
  const services = account2.delegationRequired ?? [];
@@ -338,6 +344,25 @@ function StatusBody(props) {
338
344
  });
339
345
  }
340
346
  }), null);
347
+ _$insert(_el$6, _$createComponent(Show, {
348
+ get when() {
349
+ return props.status.shellSkills?.length;
350
+ },
351
+ get children() {
352
+ return _$createComponent(Row, {
353
+ label: SHELL_LABEL,
354
+ get value() {
355
+ return shellText(props.status);
356
+ },
357
+ get fg() {
358
+ return tone("warning");
359
+ },
360
+ get muted() {
361
+ return muted();
362
+ }
363
+ });
364
+ }
365
+ }), null);
341
366
  _$insert(_el$6, _$createComponent(Row, {
342
367
  label: "Tools",
343
368
  get value() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joe-broadhead/opencode-gws",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Unofficial OpenCode plugin for Google Workspace (gws CLI)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,10 +52,10 @@
52
52
  "docs:coverage": "node scripts/generate-docs.mjs"
53
53
  },
54
54
  "optionalDependencies": {
55
- "@joe-broadhead/opencode-gws-bin-darwin-arm64": "0.0.6",
56
- "@joe-broadhead/opencode-gws-bin-darwin-x64": "0.0.6",
57
- "@joe-broadhead/opencode-gws-bin-linux-arm64-gnu": "0.0.6",
58
- "@joe-broadhead/opencode-gws-bin-linux-x64-gnu": "0.0.6"
55
+ "@joe-broadhead/opencode-gws-bin-darwin-arm64": "0.0.8",
56
+ "@joe-broadhead/opencode-gws-bin-darwin-x64": "0.0.8",
57
+ "@joe-broadhead/opencode-gws-bin-linux-arm64-gnu": "0.0.8",
58
+ "@joe-broadhead/opencode-gws-bin-linux-x64-gnu": "0.0.8"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@babel/core": "7.29.7",
@@ -39,7 +39,7 @@ If `gws_catalog` returns `unavailable`, or finds nothing for a service, this set
39
39
  ## Change: plan, then commit
40
40
 
41
41
  1. `gws_plan` with the same request shape as a read. Nothing changes yet. It returns `planId`, a redacted `preview` and `category`.
42
- 2. Show the preview. If the user only asked to see what would happen, stop here and say nothing changed.
42
+ 2. Show the preview. If the user only asked to see what would happen, stop here: say nothing changed, and name the tool that would commit it, where the user would approve it.
43
43
  3. Commit with the tool for the plan's category, passing only `{"planId": "..."}`.
44
44
 
45
45
  | Category | Tool | Asks |
@@ -77,4 +77,4 @@ If `gws_status` shows `unguardedAgentCount` above 0, tell the user once to run `
77
77
 
78
78
  ## Scripts
79
79
 
80
- For a job that runs without you, such as a cron job, a script can call the RPC `google-workspace.gws.tools`. Its methods `status`, `catalog`, `schema`, `read` and `plan` take the same input as the tools, and `describe` lists them. It runs as a session that names an agent, under that agent's rules. Create the session once with `opencode api post /api/session` and a body such as `{"title":"digest","agent":"build"}`, then call `opencode api post /api/rpc/google-workspace.gws.tools/read` with `{"input": {"sessionID": "...", "input": {...}}}`. A script can plan but never commit. A call its rules would ask about is refused with `PERMISSION_DENIED`, because no one is there to answer.
80
+ For a job that runs without you, such as a cron job, a script can call the RPC `google-workspace.gws.tools`. Its methods `status`, `catalog`, `schema`, `read` and `plan` take the same input as the tools, and `describe` lists them. It runs as a session that names an agent, under that agent's rules. Create a session for the script once with `opencode api post /api/session` and a body such as `{"title":"digest","agent":"build"}`, and keep its `data.id`. Never borrow a session found through history or a session list: the script would run as that session's agent, under its rules. Then call `opencode api post /api/rpc/google-workspace.gws.tools/read` with `{"input": {"sessionID": "...", "input": {...}}}`. A script can plan but never commit. A call its rules would ask about is refused with `PERMISSION_DENIED`, because no one is there to answer.