@lovrozagar/grunt 0.5.2 → 0.6.0

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.
Files changed (153) hide show
  1. package/.agents/agents/grunt.md +2 -24
  2. package/.agents/agents/orchestrator.md +5 -50
  3. package/.agents/skills/ask/SKILL.md +5 -12
  4. package/.agents/skills/auto/SKILL.md +7 -12
  5. package/.agents/skills/browser/SKILL.md +4 -8
  6. package/.agents/skills/clasp/SKILL.md +11 -0
  7. package/.agents/skills/commit/SKILL.md +3 -1
  8. package/.agents/skills/commit-push/SKILL.md +2 -0
  9. package/.agents/skills/commit-push-deploy/SKILL.md +2 -0
  10. package/.agents/skills/commit-push-release/SKILL.md +2 -0
  11. package/.agents/skills/explain/SKILL.md +7 -13
  12. package/.agents/skills/google-workspace/SKILL.md +13 -0
  13. package/.agents/skills/handoff/SKILL.md +7 -11
  14. package/.agents/skills/implement-plan/SKILL.md +5 -109
  15. package/.agents/skills/listen/SKILL.md +15 -0
  16. package/.agents/skills/pickup/SKILL.md +6 -12
  17. package/.agents/skills/speak/SKILL.md +13 -0
  18. package/.agents/skills/tmp/SKILL.md +6 -10
  19. package/.agents/skills/write-plan/SKILL.md +5 -77
  20. package/.claude/agents/grunt.md +2 -26
  21. package/.claude/agents/orchestrator.md +12 -50
  22. package/.claude/skills/ask/SKILL.md +5 -12
  23. package/.claude/skills/auto/SKILL.md +7 -12
  24. package/.claude/skills/browser/SKILL.md +4 -8
  25. package/.claude/skills/clasp/SKILL.md +11 -0
  26. package/.claude/skills/commit/SKILL.md +3 -1
  27. package/.claude/skills/commit-push/SKILL.md +2 -0
  28. package/.claude/skills/commit-push-deploy/SKILL.md +2 -0
  29. package/.claude/skills/commit-push-release/SKILL.md +2 -0
  30. package/.claude/skills/explain/SKILL.md +7 -13
  31. package/.claude/skills/google-workspace/SKILL.md +13 -0
  32. package/.claude/skills/handoff/SKILL.md +7 -11
  33. package/.claude/skills/implement-plan/SKILL.md +5 -109
  34. package/.claude/skills/listen/SKILL.md +15 -0
  35. package/.claude/skills/pickup/SKILL.md +6 -12
  36. package/.claude/skills/speak/SKILL.md +13 -0
  37. package/.claude/skills/tmp/SKILL.md +6 -10
  38. package/.claude/skills/write-plan/SKILL.md +5 -77
  39. package/.codex/agents/grunt.toml +2 -26
  40. package/.codex/agents/orchestrator.toml +4 -50
  41. package/.grok/agents/grunt.md +2 -24
  42. package/.grok/agents/orchestrator.md +4 -48
  43. package/.grok/global-settings.toml +1 -3
  44. package/.grok/hooks/orchestrate-parent.js +190 -358
  45. package/.grok/skills/ask/SKILL.md +5 -12
  46. package/.grok/skills/auto/SKILL.md +7 -12
  47. package/.grok/skills/browser/SKILL.md +4 -8
  48. package/.grok/skills/clasp/SKILL.md +11 -0
  49. package/.grok/skills/commit/SKILL.md +3 -1
  50. package/.grok/skills/commit-push/SKILL.md +2 -0
  51. package/.grok/skills/commit-push-deploy/SKILL.md +2 -0
  52. package/.grok/skills/commit-push-release/SKILL.md +2 -0
  53. package/.grok/skills/explain/SKILL.md +7 -13
  54. package/.grok/skills/google-workspace/SKILL.md +13 -0
  55. package/.grok/skills/handoff/SKILL.md +7 -11
  56. package/.grok/skills/implement-plan/SKILL.md +5 -109
  57. package/.grok/skills/listen/SKILL.md +15 -0
  58. package/.grok/skills/pickup/SKILL.md +6 -12
  59. package/.grok/skills/speak/SKILL.md +13 -0
  60. package/.grok/skills/tmp/SKILL.md +6 -10
  61. package/.grok/skills/write-plan/SKILL.md +5 -77
  62. package/.rulesync/hooks.jsonc +1 -1
  63. package/.rulesync/mcp-policy.jsonc +0 -2
  64. package/.rulesync/reference/INDEX.md +30 -20
  65. package/.rulesync/reference/browser.md +13 -6
  66. package/.rulesync/reference/cascade.md +6 -86
  67. package/.rulesync/reference/clasp.md +21 -0
  68. package/.rulesync/reference/google-workspace.md +79 -0
  69. package/.rulesync/reference/hooks.md +7 -11
  70. package/.rulesync/reference/implementation-format.md +113 -0
  71. package/.rulesync/reference/law.md +5 -3
  72. package/.rulesync/reference/listen.md +80 -0
  73. package/.rulesync/reference/map.md +39 -36
  74. package/.rulesync/reference/output.md +3 -51
  75. package/.rulesync/reference/plan-format.md +4 -2
  76. package/.rulesync/reference/refs-map.md +12 -5
  77. package/.rulesync/reference/rtk.md +3 -2
  78. package/.rulesync/reference/scope.md +37 -0
  79. package/.rulesync/reference/setup.md +27 -0
  80. package/.rulesync/reference/skills-map.md +13 -12
  81. package/.rulesync/reference/speak.md +59 -0
  82. package/.rulesync/rules/CLAUDE.md +3 -49
  83. package/.rulesync/rules/overview.md +9 -47
  84. package/.rulesync/skills/ask/SKILL.md +5 -12
  85. package/.rulesync/skills/auto/SKILL.md +5 -12
  86. package/.rulesync/skills/browser/SKILL.md +4 -8
  87. package/.rulesync/skills/clasp/SKILL.md +9 -0
  88. package/.rulesync/skills/commit/SKILL.md +3 -1
  89. package/.rulesync/skills/commit-push/SKILL.md +2 -0
  90. package/.rulesync/skills/commit-push-deploy/SKILL.md +2 -0
  91. package/.rulesync/skills/commit-push-release/SKILL.md +2 -0
  92. package/.rulesync/skills/explain/SKILL.md +7 -14
  93. package/.rulesync/skills/google-workspace/SKILL.md +11 -0
  94. package/.rulesync/skills/handoff/SKILL.md +7 -11
  95. package/.rulesync/skills/implement-plan/SKILL.md +5 -119
  96. package/.rulesync/skills/listen/SKILL.md +13 -0
  97. package/.rulesync/skills/pickup/SKILL.md +6 -12
  98. package/.rulesync/skills/speak/SKILL.md +13 -0
  99. package/.rulesync/skills/tmp/SKILL.md +6 -10
  100. package/.rulesync/skills/write-plan/SKILL.md +5 -84
  101. package/.rulesync/subagents/grunt.md +2 -24
  102. package/.rulesync/subagents/orchestrator.md +14 -59
  103. package/AGENTS.md +9 -47
  104. package/CHANGELOG.md +56 -0
  105. package/CLAUDE.md +1 -49
  106. package/README.md +88 -80
  107. package/cli/grunt.mjs +18 -2
  108. package/cli/grunt.test.ts +33 -0
  109. package/cli/init.mjs +103 -10
  110. package/cli/init.test.ts +147 -35
  111. package/cli/prompt.mjs +8 -0
  112. package/cli/prompt.test.ts +14 -0
  113. package/package.json +7 -2
  114. package/scripts/browser.mjs +98 -5
  115. package/scripts/doctor.mjs +82 -1
  116. package/scripts/emit-gemini.mjs +1 -1
  117. package/scripts/emit-mcp-policy.mjs +1 -1
  118. package/scripts/gate-fat-tools.mjs +223 -173
  119. package/scripts/google-workspace.mjs +713 -0
  120. package/scripts/grunt-job.mjs +453 -69
  121. package/scripts/listen.mjs +829 -0
  122. package/scripts/parse-need.mjs +21 -2
  123. package/scripts/persist-implementation.mjs +278 -0
  124. package/scripts/scrub-spawn-prompt.mjs +3 -50
  125. package/scripts/setup.mjs +746 -0
  126. package/scripts/speak.mjs +291 -0
  127. package/.agents/agents/implementer.md +0 -32
  128. package/.agents/agents/thinker.md +0 -29
  129. package/.agents/skills/cascade/SKILL.md +0 -28
  130. package/.agents/skills/parent/SKILL.md +0 -6
  131. package/.agents/skills/solo/SKILL.md +0 -25
  132. package/.claude/agents/implementer.md +0 -37
  133. package/.claude/agents/thinker.md +0 -36
  134. package/.claude/skills/cascade/SKILL.md +0 -28
  135. package/.claude/skills/parent/SKILL.md +0 -6
  136. package/.claude/skills/solo/SKILL.md +0 -25
  137. package/.codex/agents/implementer.toml +0 -26
  138. package/.codex/agents/thinker.toml +0 -26
  139. package/.grok/agents/implementer.md +0 -27
  140. package/.grok/agents/thinker.md +0 -27
  141. package/.grok/parent.md +0 -1
  142. package/.grok/roles/implementer.toml +0 -5
  143. package/.grok/roles/thinker.toml +0 -5
  144. package/.grok/skills/cascade/SKILL.md +0 -28
  145. package/.grok/skills/parent/SKILL.md +0 -6
  146. package/.grok/skills/solo/SKILL.md +0 -25
  147. package/.rulesync/grunt.config.jsonc +0 -7
  148. package/.rulesync/skills/cascade/SKILL.md +0 -28
  149. package/.rulesync/skills/parent/SKILL.md +0 -6
  150. package/.rulesync/skills/solo/SKILL.md +0 -25
  151. package/.rulesync/subagents/implementer.md +0 -49
  152. package/.rulesync/subagents/thinker.md +0 -49
  153. package/scripts/grunt-config.mjs +0 -50
@@ -0,0 +1,713 @@
1
+ #!/usr/bin/env node
2
+ /** Google Workspace verbs: sheet/doc/slide/meeting/mail. */
3
+ import { spawnSync } from "node:child_process";
4
+ import crypto from "node:crypto";
5
+ import fs from "node:fs";
6
+ import http from "node:http";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+
11
+ const DRIVE = "https://www.googleapis.com/drive/v3";
12
+ const DRIVE_UPLOAD = "https://www.googleapis.com/upload/drive/v3";
13
+ const SHEETS = "https://sheets.googleapis.com/v4";
14
+ const DOCS = "https://docs.googleapis.com/v1";
15
+ const CALENDAR = "https://www.googleapis.com/calendar/v3";
16
+ const GMAIL = "https://gmail.googleapis.com/gmail/v1";
17
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
18
+
19
+ /** Extra clasp login scopes. Clasp's default consent is Apps Script + Drive only. */
20
+ export const EXTRA_SCOPES = [
21
+ "https://www.googleapis.com/auth/calendar",
22
+ "https://www.googleapis.com/auth/calendar.events",
23
+ "https://www.googleapis.com/auth/gmail.modify",
24
+ "https://www.googleapis.com/auth/gmail.send",
25
+ "https://www.googleapis.com/auth/spreadsheets",
26
+ "https://www.googleapis.com/auth/documents",
27
+ "https://www.googleapis.com/auth/presentations",
28
+ "https://www.googleapis.com/auth/forms.body",
29
+ "https://www.googleapis.com/auth/drive",
30
+ "https://www.googleapis.com/auth/contacts",
31
+ "https://www.googleapis.com/auth/tasks",
32
+ ];
33
+ const MIME = {
34
+ sheet: "application/vnd.google-apps.spreadsheet",
35
+ doc: "application/vnd.google-apps.document",
36
+ slide: "application/vnd.google-apps.presentation",
37
+ };
38
+
39
+ let accountId = "default";
40
+
41
+ export function setAccount(id) {
42
+ const s = String(id || "default").trim() || "default";
43
+ if (!/^[a-zA-Z0-9_-]{1,40}$/.test(s)) {
44
+ throw new Error("bad --account (letters, numbers, _- only)");
45
+ }
46
+ accountId = s;
47
+ return accountId;
48
+ }
49
+
50
+ function gruntHome() {
51
+ const d = path.join(os.homedir(), ".grunt");
52
+ fs.mkdirSync(d, { recursive: true, mode: 0o700 });
53
+ return d;
54
+ }
55
+
56
+ function accountDir() {
57
+ return path.join(gruntHome(), "workspace", accountId);
58
+ }
59
+
60
+ export function tokenStorePath() {
61
+ const nested = path.join(accountDir(), "tokens.json");
62
+ if (accountId === "default") {
63
+ const legacy = path.join(gruntHome(), "workspace-tokens.json");
64
+ if (!fs.existsSync(nested) && fs.existsSync(legacy)) return legacy;
65
+ }
66
+ return nested;
67
+ }
68
+
69
+ export function credsDefaultPath() {
70
+ const nested = path.join(accountDir(), "google-oauth.json");
71
+ if (accountId === "default") {
72
+ const legacy = path.join(gruntHome(), "google-oauth.json");
73
+ if (!fs.existsSync(nested) && fs.existsSync(legacy)) return legacy;
74
+ }
75
+ return nested;
76
+ }
77
+
78
+ function adcPath() {
79
+ return path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json");
80
+ }
81
+
82
+ function clasprcPath() {
83
+ return path.join(os.homedir(), ".clasprc.json");
84
+ }
85
+
86
+ function readJson(p) {
87
+ try {
88
+ return JSON.parse(fs.readFileSync(p, "utf8"));
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function writeSecretJson(p, obj) {
95
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
96
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
97
+ }
98
+
99
+ function tokenFromStore() {
100
+ const p = tokenStorePath();
101
+ const j = readJson(p);
102
+ if (j && j.refresh_token && j.client_id) return { ...j, _path: p };
103
+ return null;
104
+ }
105
+
106
+ function tokenFromAdc() {
107
+ const p = adcPath();
108
+ const j = readJson(p);
109
+ if (j && j.type === "authorized_user" && j.refresh_token && j.client_id) {
110
+ return { ...j, _path: p };
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function tokenFromClasprc() {
116
+ const p = clasprcPath();
117
+ const j = readJson(p);
118
+ const t = j && j.tokens && j.tokens.default;
119
+ if (!t || !t.refresh_token) return null;
120
+ return { ...t, _path: p, _clasprc: true };
121
+ }
122
+
123
+ function loadToken() {
124
+ return tokenFromStore() || tokenFromAdc() || tokenFromClasprc();
125
+ }
126
+
127
+ function persistToken(t, access, expiry) {
128
+ t.access_token = access;
129
+ t.expiry_date = expiry;
130
+ if (t._clasprc) {
131
+ const raw = readJson(t._path) || { tokens: {} };
132
+ raw.tokens = raw.tokens || {};
133
+ const rest = { ...t };
134
+ delete rest._path;
135
+ delete rest._clasprc;
136
+ raw.tokens.default = rest;
137
+ writeSecretJson(t._path, raw);
138
+ return;
139
+ }
140
+ const dest = t._path && t._path !== adcPath() ? t._path : tokenStorePath();
141
+ const rest = { ...t };
142
+ delete rest._path;
143
+ delete rest._clasprc;
144
+ writeSecretJson(dest, rest);
145
+ }
146
+
147
+ async function accessToken() {
148
+ const t = loadToken();
149
+ if (!t) throw new Error("not logged in; run node scripts/google-workspace.mjs login");
150
+ if (t.access_token && Number(t.expiry_date) > Date.now() + 60_000) {
151
+ return t.access_token;
152
+ }
153
+ const body = new URLSearchParams({
154
+ client_id: t.client_id,
155
+ client_secret: t.client_secret || "",
156
+ refresh_token: t.refresh_token,
157
+ grant_type: "refresh_token",
158
+ });
159
+ const r = await fetch(TOKEN_URL, {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
162
+ body,
163
+ });
164
+ const j = await r.json();
165
+ if (!j.access_token) {
166
+ throw new Error(j.error_description || j.error || "token refresh failed");
167
+ }
168
+ const expiry = j.expiry_date
169
+ ? j.expiry_date
170
+ : Date.now() + Number(j.expires_in || 3600) * 1000;
171
+ persistToken(t, j.access_token, expiry);
172
+ return j.access_token;
173
+ }
174
+
175
+ function whichGcloud() {
176
+ const r = spawnSync("gcloud", ["--version"], { encoding: "utf8" });
177
+ return r.status === 0;
178
+ }
179
+
180
+ export function openUrl(url, { platform = process.platform, spawnSyncFn = spawnSync } = {}) {
181
+ if (platform === "darwin") {
182
+ spawnSyncFn("open", [url], { stdio: "ignore" });
183
+ } else if (platform === "win32") {
184
+ spawnSyncFn("cmd", ["/c", "start", "", url], {
185
+ stdio: "ignore",
186
+ windowsVerbatimArguments: true,
187
+ });
188
+ } else {
189
+ spawnSyncFn("xdg-open", [url], { stdio: "ignore" });
190
+ }
191
+ }
192
+
193
+ export async function loginLoopback(credsPath) {
194
+ const raw = readJson(credsPath);
195
+ const inst = raw && (raw.installed || raw.web);
196
+ if (!inst || !inst.client_id) {
197
+ throw new Error("OAuth JSON needs installed.client_id (Desktop app download)");
198
+ }
199
+ const verifier = crypto.randomBytes(32).toString("base64url");
200
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
201
+ const server = http.createServer();
202
+ await new Promise((res, rej) => {
203
+ server.listen(0, "127.0.0.1", res);
204
+ server.on("error", rej);
205
+ });
206
+ const port = server.address().port;
207
+ const redirect = `http://127.0.0.1:${port}`;
208
+ const scope = [
209
+ ...EXTRA_SCOPES,
210
+ "https://www.googleapis.com/auth/userinfo.email",
211
+ "https://www.googleapis.com/auth/userinfo.profile",
212
+ "openid",
213
+ ].join(" ");
214
+ const auth =
215
+ "https://accounts.google.com/o/oauth2/v2/auth?" +
216
+ new URLSearchParams({
217
+ client_id: inst.client_id,
218
+ redirect_uri: redirect,
219
+ response_type: "code",
220
+ access_type: "offline",
221
+ prompt: "consent",
222
+ scope,
223
+ code_challenge: challenge,
224
+ code_challenge_method: "S256",
225
+ }).toString();
226
+ const code = await new Promise((resolve, reject) => {
227
+ const timer = setTimeout(() => reject(new Error("login timed out")), 180_000);
228
+ server.on("request", (req, res) => {
229
+ const u = new URL(req.url, redirect);
230
+ const c = u.searchParams.get("code");
231
+ const err = u.searchParams.get("error");
232
+ res.writeHead(200, { "Content-Type": "text/plain" });
233
+ res.end(c ? "Logged in. Return to the terminal." : String(err || "missing code"));
234
+ clearTimeout(timer);
235
+ if (c) resolve(c);
236
+ else reject(new Error(err || "missing code"));
237
+ });
238
+ openUrl(auth);
239
+ });
240
+ server.close();
241
+ const body = new URLSearchParams({
242
+ client_id: inst.client_id,
243
+ client_secret: inst.client_secret || "",
244
+ code,
245
+ code_verifier: verifier,
246
+ redirect_uri: redirect,
247
+ grant_type: "authorization_code",
248
+ });
249
+ const r = await fetch(TOKEN_URL, {
250
+ method: "POST",
251
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
252
+ body,
253
+ });
254
+ const j = await r.json();
255
+ if (!j.access_token) throw new Error(j.error_description || j.error || "token exchange failed");
256
+ writeSecretJson(tokenStorePath(), {
257
+ type: "authorized_user",
258
+ client_id: inst.client_id,
259
+ client_secret: inst.client_secret || "",
260
+ refresh_token: j.refresh_token || (loadToken() || {}).refresh_token,
261
+ access_token: j.access_token,
262
+ expiry_date: Date.now() + Number(j.expires_in || 3600) * 1000,
263
+ });
264
+ }
265
+
266
+ function loginGcloud() {
267
+ const scopes = [
268
+ "https://www.googleapis.com/auth/cloud-platform",
269
+ "https://www.googleapis.com/auth/userinfo.email",
270
+ ...EXTRA_SCOPES,
271
+ ].join(",");
272
+ const r = spawnSync(
273
+ "gcloud",
274
+ ["auth", "application-default", "login", `--scopes=${scopes}`],
275
+ { stdio: "inherit" },
276
+ );
277
+ if (r.status !== 0) throw new Error("gcloud auth application-default login failed");
278
+ }
279
+
280
+ async function runLogin(flags) {
281
+ const creds = flags.creds || credsDefaultPath();
282
+ if (fs.existsSync(creds)) {
283
+ await loginLoopback(creds);
284
+ return ok(`logged in\n${tokenStorePath()}`);
285
+ }
286
+ if (whichGcloud()) {
287
+ loginGcloud();
288
+ return ok("logged in via gcloud ADC");
289
+ }
290
+ return fail(
291
+ `missing Desktop OAuth JSON (${creds}). setup: node scripts/setup.mjs google-workspace`,
292
+ );
293
+ }
294
+
295
+ async function gfetch(url, init = {}) {
296
+ const token = await accessToken();
297
+ const r = await fetch(url, {
298
+ ...init,
299
+ headers: {
300
+ Authorization: "Bearer " + token,
301
+ ...(init.body && typeof init.body === "string"
302
+ ? { "Content-Type": "application/json" }
303
+ : {}),
304
+ ...(init.headers || {}),
305
+ },
306
+ });
307
+ const text = await r.text();
308
+ let json = null;
309
+ try {
310
+ json = text ? JSON.parse(text) : null;
311
+ } catch {
312
+ json = { raw: text.slice(0, 400) };
313
+ }
314
+ return { status: r.status, json, ok: r.status >= 200 && r.status < 300 };
315
+ }
316
+
317
+ function parseArgv(argv) {
318
+ const out = { _: [], flags: {} };
319
+ for (let i = 0; i < argv.length; i++) {
320
+ const a = argv[i];
321
+ if (a === "--") {
322
+ out._.push(...argv.slice(i + 1));
323
+ break;
324
+ }
325
+ if (a.startsWith("--")) {
326
+ const key = a.slice(2);
327
+ const nxt = argv[i + 1];
328
+ if (nxt && !nxt.startsWith("--")) {
329
+ out.flags[key] = nxt;
330
+ i += 1;
331
+ } else out.flags[key] = "1";
332
+ } else out._.push(a);
333
+ }
334
+ return out;
335
+ }
336
+
337
+ function fail(msg) {
338
+ process.stderr.write(msg.trimEnd() + "\n");
339
+ return 1;
340
+ }
341
+
342
+ function ok(msg) {
343
+ process.stdout.write(msg.trimEnd() + "\n");
344
+ return 0;
345
+ }
346
+
347
+ async function whoami() {
348
+ const r = await gfetch("https://www.googleapis.com/oauth2/v2/userinfo");
349
+ if (!r.ok) return fail(r.json?.error?.message || "whoami failed");
350
+ return ok(r.json.email || JSON.stringify(r.json));
351
+ }
352
+
353
+ async function createFile({ kind, title, csv, body }) {
354
+ const mime = MIME[kind];
355
+ if (!mime) throw new Error("unknown kind");
356
+ const name = title || `grunt-${kind}`;
357
+ if (kind === "sheet" && csv) {
358
+ const meta = JSON.stringify({ name, mimeType: mime });
359
+ const boundary = "grunt_ws_" + Date.now();
360
+ const blob =
361
+ `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${meta}\r\n` +
362
+ `--${boundary}\r\nContent-Type: text/csv\r\n\r\n${csv}\r\n` +
363
+ `--${boundary}--`;
364
+ const token = await accessToken();
365
+ const r = await fetch(
366
+ `${DRIVE_UPLOAD}/files?uploadType=multipart&fields=id,name,webViewLink`,
367
+ {
368
+ method: "POST",
369
+ headers: {
370
+ Authorization: "Bearer " + token,
371
+ "Content-Type": `multipart/related; boundary=${boundary}`,
372
+ },
373
+ body: blob,
374
+ },
375
+ );
376
+ const json = await r.json();
377
+ if (!r.ok) throw new Error(json.error?.message || "upload failed");
378
+ return json;
379
+ }
380
+ if (kind === "doc" && body) {
381
+ const meta = JSON.stringify({ name, mimeType: mime });
382
+ const boundary = "grunt_ws_" + Date.now();
383
+ const blob =
384
+ `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${meta}\r\n` +
385
+ `--${boundary}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n${body}\r\n` +
386
+ `--${boundary}--`;
387
+ const token = await accessToken();
388
+ const r = await fetch(
389
+ `${DRIVE_UPLOAD}/files?uploadType=multipart&fields=id,name,webViewLink`,
390
+ {
391
+ method: "POST",
392
+ headers: {
393
+ Authorization: "Bearer " + token,
394
+ "Content-Type": `multipart/related; boundary=${boundary}`,
395
+ },
396
+ body: blob,
397
+ },
398
+ );
399
+ const json = await r.json();
400
+ if (!r.ok) throw new Error(json.error?.message || "upload failed");
401
+ return json;
402
+ }
403
+ const r = await gfetch(`${DRIVE}/files?fields=id,name,webViewLink`, {
404
+ method: "POST",
405
+ body: JSON.stringify({ name, mimeType: mime }),
406
+ });
407
+ if (!r.ok) throw new Error(r.json?.error?.message || "create failed");
408
+ return r.json;
409
+ }
410
+
411
+ function fileLine(j) {
412
+ return `${j.name}\n${j.webViewLink || j.id}\nid=${j.id}`;
413
+ }
414
+
415
+ export function parseCsv(text) {
416
+ return String(text || "")
417
+ .split(/\r?\n/)
418
+ .map((line) => line.split(",").map((c) => c.trim()))
419
+ .filter((row) => row.some((c) => c !== ""));
420
+ }
421
+
422
+ async function sheetGet({ id, range }) {
423
+ if (!id) return fail("sheet get needs --id");
424
+ const r = await gfetch(
425
+ `${SHEETS}/spreadsheets/${id}/values/${encodeURIComponent(range || "A1:Z50")}`,
426
+ );
427
+ if (!r.ok) return fail(r.json?.error?.message || "sheet get failed");
428
+ const rows = r.json.values || [];
429
+ if (!rows.length) return ok("(empty)");
430
+ return ok(rows.map((row) => row.join("\t")).join("\n"));
431
+ }
432
+
433
+ async function sheetAppend({ id, csv, range }) {
434
+ if (!id) return fail("sheet append needs --id");
435
+ const values = parseCsv(csv);
436
+ if (!values.length) return fail("sheet append needs --csv");
437
+ const r = await gfetch(
438
+ `${SHEETS}/spreadsheets/${id}/values/${encodeURIComponent(range || "A1")}:append?valueInputOption=USER_ENTERED`,
439
+ { method: "POST", body: JSON.stringify({ values }) },
440
+ );
441
+ if (!r.ok) return fail(r.json?.error?.message || "sheet append failed");
442
+ return ok(`appended ${r.json.updates?.updatedRows || values.length}`);
443
+ }
444
+
445
+ async function sheetClear({ id, range }) {
446
+ if (!id) return fail("sheet clear needs --id");
447
+ const r = await gfetch(
448
+ `${SHEETS}/spreadsheets/${id}/values/${encodeURIComponent(range || "A1:Z")}:clear`,
449
+ { method: "POST", body: "{}" },
450
+ );
451
+ if (!r.ok) return fail(r.json?.error?.message || "sheet clear failed");
452
+ return ok(`cleared ${r.json.clearedRange || range || "A1:Z"}`);
453
+ }
454
+
455
+ async function docAppend({ id, body }) {
456
+ if (!id) return fail("doc append needs --id");
457
+ const r = await gfetch(`${DOCS}/documents/${id}:batchUpdate`, {
458
+ method: "POST",
459
+ body: JSON.stringify({
460
+ requests: [
461
+ {
462
+ insertText: {
463
+ endOfSegmentLocation: {},
464
+ text: String(body ?? ""),
465
+ },
466
+ },
467
+ ],
468
+ }),
469
+ });
470
+ if (!r.ok) return fail(r.json?.error?.message || "doc append failed");
471
+ return ok(`appended id=${id}`);
472
+ }
473
+
474
+ async function driveList({ query, n }) {
475
+ const q = new URLSearchParams({
476
+ pageSize: String(n || 10),
477
+ fields: "files(id,name,mimeType,webViewLink)",
478
+ });
479
+ if (query) q.set("q", query);
480
+ const r = await gfetch(`${DRIVE}/files?${q}`);
481
+ if (!r.ok) return fail(r.json?.error?.message || "drive list failed");
482
+ const files = r.json.files || [];
483
+ if (!files.length) return ok("no files");
484
+ return ok(
485
+ files
486
+ .map((f) => `${f.name}\t${f.id}\t${f.webViewLink || f.mimeType || ""}`)
487
+ .join("\n"),
488
+ );
489
+ }
490
+
491
+ async function sheetSet({ id, cell, value }) {
492
+ const range = encodeURIComponent(cell || "A1");
493
+ const r = await gfetch(
494
+ `${SHEETS}/spreadsheets/${id}/values/${range}?valueInputOption=USER_ENTERED`,
495
+ {
496
+ method: "PUT",
497
+ body: JSON.stringify({ values: [[value ?? ""]] }),
498
+ },
499
+ );
500
+ if (r.ok) return ok(`updated ${r.json.updatedRange || cell}`);
501
+ const msg = r.json?.error?.message || "sheet set failed";
502
+ return fail(
503
+ msg.includes("has not been used") || msg.includes("SERVICE_DISABLED")
504
+ ? `Sheets API is off on this OAuth project. Create the sheet with data instead: workspace sheet create --title … --csv "A1value"`
505
+ : msg,
506
+ );
507
+ }
508
+
509
+ function toGCalStamp(iso) {
510
+ const d = new Date(iso);
511
+ if (Number.isNaN(d.getTime())) return "";
512
+ return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
513
+ }
514
+
515
+ export function loginArgs() {
516
+ return ["login", "--extra-scopes", EXTRA_SCOPES.join(",")];
517
+ }
518
+
519
+ async function scopes() {
520
+ const token = await accessToken();
521
+ const r = await fetch(
522
+ "https://oauth2.googleapis.com/tokeninfo?access_token=" +
523
+ encodeURIComponent(token),
524
+ );
525
+ const j = await r.json();
526
+ const list = String(j.scope || "")
527
+ .split(/\s+/)
528
+ .filter(Boolean)
529
+ .sort();
530
+ return ok(list.join("\n") || j.error || "no scopes");
531
+ }
532
+
533
+ async function mailSend({ to, subject, body }) {
534
+ if (!to) return fail("mail send needs --to");
535
+ const raw = [
536
+ `To: ${to}`,
537
+ `Subject: ${subject || "(no subject)"}`,
538
+ "Content-Type: text/plain; charset=utf-8",
539
+ "",
540
+ body || "",
541
+ ].join("\r\n");
542
+ const encoded = Buffer.from(raw)
543
+ .toString("base64")
544
+ .replace(/\+/g, "-")
545
+ .replace(/\//g, "_")
546
+ .replace(/=+$/, "");
547
+ const r = await gfetch(`${GMAIL}/users/me/messages/send`, {
548
+ method: "POST",
549
+ body: JSON.stringify({ raw: encoded }),
550
+ });
551
+ if (!r.ok) return fail(r.json?.error?.message || "mail send failed");
552
+ return ok(`sent id=${r.json.id}`);
553
+ }
554
+
555
+ async function mailList({ query, n }) {
556
+ const q = new URLSearchParams({ maxResults: String(n || 5) });
557
+ if (query) q.set("q", query);
558
+ const r = await gfetch(`${GMAIL}/users/me/messages?${q}`);
559
+ if (!r.ok) return fail(r.json?.error?.message || "mail list failed");
560
+ const ids = (r.json.messages || []).map((m) => m.id);
561
+ return ok(ids.length ? ids.join("\n") : "no messages");
562
+ }
563
+
564
+ async function meetingCreate({ title, start, end, when, attendees }) {
565
+ let startIso = start;
566
+ let endIso = end;
567
+ if (!startIso && when) startIso = when;
568
+ if (!startIso) startIso = new Date(Date.now() + 3600_000).toISOString();
569
+ if (!endIso) endIso = new Date(new Date(startIso).getTime() + 3600_000).toISOString();
570
+ const event = {
571
+ summary: title || "meeting",
572
+ start: { dateTime: startIso },
573
+ end: { dateTime: endIso },
574
+ };
575
+ const emails = String(attendees || "")
576
+ .split(/[,;\s]+/)
577
+ .map((e) => e.trim())
578
+ .filter(Boolean);
579
+ if (emails.length) {
580
+ event.attendees = emails.map((email) => ({ email }));
581
+ }
582
+ const r = await gfetch(`${CALENDAR}/calendars/primary/events`, {
583
+ method: "POST",
584
+ body: JSON.stringify(event),
585
+ });
586
+ if (r.ok) {
587
+ return ok(`${r.json.summary}\n${r.json.htmlLink}\nid=${r.json.id}`);
588
+ }
589
+ const stamp0 = toGCalStamp(startIso);
590
+ const stamp1 = toGCalStamp(endIso);
591
+ const template =
592
+ "https://calendar.google.com/calendar/render?action=TEMPLATE" +
593
+ `&text=${encodeURIComponent(event.summary)}` +
594
+ (stamp0 && stamp1 ? `&dates=${stamp0}/${stamp1}` : "");
595
+ return fail(
596
+ `Calendar API blocked (${r.json?.error?.message || r.status}). Open to confirm:\n${template}`,
597
+ );
598
+ }
599
+
600
+ function listAccounts() {
601
+ const root = path.join(gruntHome(), "workspace");
602
+ const names = new Set();
603
+ if (fs.existsSync(path.join(gruntHome(), "google-oauth.json")) ||
604
+ fs.existsSync(path.join(gruntHome(), "workspace-tokens.json"))) {
605
+ names.add("default");
606
+ }
607
+ try {
608
+ for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
609
+ if (ent.isDirectory() && /^[a-zA-Z0-9_-]{1,40}$/.test(ent.name)) names.add(ent.name);
610
+ }
611
+ } catch {
612
+ /* none */
613
+ }
614
+ const lines = [...names].sort().map((n) => {
615
+ setAccount(n);
616
+ const creds = fs.existsSync(credsDefaultPath()) ? "oauth" : "-";
617
+ const tok = fs.existsSync(tokenStorePath()) ? "tokens" : "-";
618
+ return `${n} ${creds} ${tok}`;
619
+ });
620
+ setAccount("default");
621
+ return lines.length ? lines.join("\n") : "no accounts";
622
+ }
623
+
624
+ async function main(argv = process.argv.slice(2)) {
625
+ const { _, flags } = parseArgv(argv);
626
+ setAccount(flags.account || process.env.WORKSPACE_ACCOUNT || "default");
627
+ const verb = (_[0] || "").toLowerCase();
628
+ const noun = (_[1] || "").toLowerCase();
629
+ try {
630
+ if (verb === "accounts") return ok(listAccounts());
631
+ if (verb === "whoami") return await whoami();
632
+ if (verb === "scopes") return await scopes();
633
+ if (verb === "login") return await runLogin(flags);
634
+ if (verb === "mail" && noun === "send") {
635
+ return await mailSend({
636
+ to: flags.to,
637
+ subject: flags.subject,
638
+ body: flags.body,
639
+ });
640
+ }
641
+ if (verb === "mail" && (noun === "list" || noun === "ls")) {
642
+ return await mailList({ query: flags.query || flags.q, n: flags.n });
643
+ }
644
+ if (verb === "sheet" && noun === "create") {
645
+ const j = await createFile({
646
+ kind: "sheet",
647
+ title: flags.title,
648
+ csv: flags.csv,
649
+ });
650
+ return ok(fileLine(j));
651
+ }
652
+ if (verb === "sheet" && noun === "set") {
653
+ return await sheetSet({
654
+ id: flags.id,
655
+ cell: flags.cell || flags.range,
656
+ value: flags.value ?? flags.a1,
657
+ });
658
+ }
659
+ if (verb === "sheet" && noun === "get") {
660
+ return await sheetGet({ id: flags.id, range: flags.range || flags.cell });
661
+ }
662
+ if (verb === "sheet" && noun === "append") {
663
+ return await sheetAppend({
664
+ id: flags.id,
665
+ csv: flags.csv,
666
+ range: flags.range || flags.cell,
667
+ });
668
+ }
669
+ if (verb === "sheet" && noun === "clear") {
670
+ return await sheetClear({ id: flags.id, range: flags.range || flags.cell });
671
+ }
672
+ if (verb === "doc" && noun === "append") {
673
+ return await docAppend({ id: flags.id, body: flags.body });
674
+ }
675
+ if (verb === "drive" && (noun === "list" || noun === "ls" || !noun)) {
676
+ return await driveList({ query: flags.query || flags.q, n: flags.n });
677
+ }
678
+ if (verb === "doc" && noun === "create") {
679
+ const j = await createFile({
680
+ kind: "doc",
681
+ title: flags.title,
682
+ body: flags.body,
683
+ });
684
+ return ok(fileLine(j));
685
+ }
686
+ if ((verb === "slide" || verb === "slides") && noun === "create") {
687
+ const j = await createFile({ kind: "slide", title: flags.title });
688
+ return ok(fileLine(j));
689
+ }
690
+ if ((verb === "meeting" || verb === "event") && noun === "create") {
691
+ return await meetingCreate({
692
+ title: flags.title,
693
+ start: flags.start,
694
+ end: flags.end,
695
+ when: flags.when,
696
+ attendees: flags.attendees || flags.to,
697
+ });
698
+ }
699
+ return fail(
700
+ "usage: google-workspace [--account NAME] whoami | scopes | accounts | login | sheet create --title T [--csv csv] | sheet set --id ID --cell A1 --value V | sheet get --id ID [--range A1:B2] | sheet append --id ID --csv csv | sheet clear --id ID [--range A1:Z] | doc create --title T [--body text] | doc append --id ID --body text | drive list [--query Q] | slide create --title T | meeting create --title T [--start ISO --end ISO] [--attendees a@b,c@d] | mail send --to E --subject S --body T | mail list",
701
+ );
702
+ } catch (e) {
703
+ return fail(String(e && e.message ? e.message : e));
704
+ }
705
+ }
706
+
707
+ export { parseArgv, createFile, meetingCreate, whoami, accessToken, main };
708
+
709
+ const thisFile = fileURLToPath(import.meta.url);
710
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
711
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
712
+ main().then((code) => process.exit(code));
713
+ }