@opencomputer/cli 0.3.9 → 0.3.11

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.
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { startGateway } from "./local.js";
4
+ test("the local gateway proxies connection list and calendar requests", async () => {
5
+ const originalFetch = globalThis.fetch;
6
+ const upstream = [];
7
+ globalThis.fetch = (async (input, init) => {
8
+ const url = String(input);
9
+ upstream.push({ url, init });
10
+ if (url.endsWith("/connections")) {
11
+ return Response.json({ connections: [] });
12
+ }
13
+ return Response.json({
14
+ service: "calendar",
15
+ status: "pending",
16
+ authorizationUrl: "https://connect.composio.dev/link/test",
17
+ });
18
+ });
19
+ const gateway = await startGateway({
20
+ apiUrl: "https://mo-oc-dev.com",
21
+ apiKey: "oc_test",
22
+ });
23
+ try {
24
+ const headers = {
25
+ authorization: `Bearer ${gateway.token}`,
26
+ "content-type": "application/json",
27
+ };
28
+ const listed = await originalFetch(`${gateway.url}/opencomputer/fetch`, {
29
+ method: "POST",
30
+ headers,
31
+ body: JSON.stringify({ action: "list" }),
32
+ });
33
+ assert.equal(listed.status, 200);
34
+ assert.equal(upstream[0]?.url, "https://mo-oc-dev.com/api/managed-agents/connections");
35
+ assert.equal(upstream[0]?.init?.method, "GET");
36
+ const requested = await originalFetch(`${gateway.url}/opencomputer/fetch`, {
37
+ method: "POST",
38
+ headers,
39
+ body: JSON.stringify({
40
+ action: "request",
41
+ service: "calendar",
42
+ label: "work-calendar",
43
+ }),
44
+ });
45
+ assert.equal(requested.status, 200);
46
+ assert.equal(upstream[1]?.url, "https://mo-oc-dev.com/api/managed-agents/connections/google/link");
47
+ assert.equal(upstream[1]?.init?.method, "POST");
48
+ assert.deepEqual(JSON.parse(String(upstream[1]?.init?.body)), {
49
+ service: "calendar",
50
+ label: "work-calendar",
51
+ });
52
+ }
53
+ finally {
54
+ await gateway.close();
55
+ globalThis.fetch = originalFetch;
56
+ }
57
+ });
58
+ //# sourceMappingURL=local.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local.test.js","sourceRoot":"","sources":["../src/local.test.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE1C,IAAI,CAAC,iEAAiE,EAAE,KAAK,IAAI,EAAE;IACjF,MAAM,aAAa,GAAG,UAAU,CAAC,KAAK,CAAC;IACvC,MAAM,QAAQ,GAA+C,EAAE,CAAC;IAChE,UAAU,CAAC,KAAK,GAAG,CAAC,KAAK,EAAE,KAA6B,EAAE,IAAkB,EAAE,EAAE;QAC9E,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QAC1B,QAAQ,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC;QAC7B,IAAI,GAAG,CAAC,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC;YACjC,OAAO,QAAQ,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,EAAE,EAAE,CAAC,CAAC;QAC5C,CAAC;QACD,OAAO,QAAQ,CAAC,IAAI,CAAC;YACnB,OAAO,EAAE,UAAU;YACnB,MAAM,EAAE,SAAS;YACjB,gBAAgB,EAAE,wCAAwC;SAC3D,CAAC,CAAC;IACL,CAAC,CAAiB,CAAC;IAEnB,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC;QACjC,MAAM,EAAE,uBAAuB;QAC/B,MAAM,EAAE,SAAS;KAClB,CAAC,CAAC;IACH,IAAI,CAAC;QACH,MAAM,OAAO,GAAG;YACd,aAAa,EAAE,UAAU,OAAO,CAAC,KAAK,EAAE;YACxC,cAAc,EAAE,kBAAkB;SACnC,CAAC;QACF,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,GAAG,OAAO,CAAC,GAAG,qBAAqB,EAAE;YACtE,MAAM,EAAE,MAAM;YACd,OAAO;YACP,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;SACzC,CAAC,CAAC;QACH,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QACjC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,sDAAsD,CAAC,CAAC;QACvF,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;QAE/C,MAAM,SAAS,GAAG,MAAM,aAAa,CAAC,GAAG,OAAO,CAAC,GAAG,qBAAqB,EAAE;YACzE,MAAM,EAAE,MAAM;YACd,OAAO;YACP,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;gBACnB,MAAM,EAAE,SAAS;gBACjB,OAAO,EAAE,UAAU;gBACnB,KAAK,EAAE,eAAe;aACvB,CAAC;SACH,CAAC,CAAC;QACH,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QACpC,MAAM,CAAC,KAAK,CACV,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,EAChB,kEAAkE,CACnE,CAAC;QACF,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;QAChD,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,EAAE;YAC5D,OAAO,EAAE,UAAU;YACnB,KAAK,EAAE,eAAe;SACvB,CAAC,CAAC;IACL,CAAC;YAAS,CAAC;QACT,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC;QACtB,UAAU,CAAC,KAAK,GAAG,aAAa,CAAC;IACnC,CAAC;AACH,CAAC,CAAC,CAAC"}
package/dist/project.d.ts CHANGED
@@ -20,6 +20,7 @@ export declare function writeManifest(root: string, manifest: AgentManifest): Pr
20
20
  export declare function readManifest(root: string): Promise<AgentManifest>;
21
21
  export declare function findAgentRoot(startDirectory?: string): Promise<string | undefined>;
22
22
  export declare function addGmailTools(root: string): Promise<string[]>;
23
+ export declare function addCalendarTools(root: string): Promise<string[]>;
23
24
  export declare function addSlackChannel(root: string): Promise<string[]>;
24
25
  export declare function initializeAgentProject(template: ManagedAgentTemplate, directory: string): Promise<{
25
26
  root: string;
package/dist/project.js CHANGED
@@ -59,9 +59,15 @@ async function prepareInitializationTarget(root, template) {
59
59
  ...(template.integrations.includes("Gmail")
60
60
  ? ["tools/gmail.ts", "connections/google.json"]
61
61
  : []),
62
+ ...(template.integrations.includes("Google Calendar")
63
+ ? ["tools/calendar.ts", "connections/google.json"]
64
+ : []),
62
65
  ...(template.id === "email-triage"
63
66
  ? ["skills/triage-inbox/SKILL.md", "evals/triage-cases.md"]
64
67
  : []),
68
+ ...(template.id === "pto-calendar"
69
+ ? ["skills/manage-pto/SKILL.md", "evals/pto-cases.md"]
70
+ : []),
65
71
  ];
66
72
  const conflicts = [];
67
73
  for (const path of reserved) {
@@ -168,6 +174,51 @@ For a later mailbox-changing request:
168
174
 
169
175
  ## Example requests
170
176
 
177
+ ${template.suggestedPrompts.map((prompt) => `- ${prompt}`).join("\n")}
178
+ `;
179
+ }
180
+ if (template.id === "pto-calendar") {
181
+ return `# ${template.name}
182
+
183
+ You are a careful PTO calendar assistant.
184
+
185
+ ${template.description}
186
+
187
+ ## Default workflow
188
+
189
+ 1. Use \`calendar_list\` to confirm which calendar and connection the user
190
+ intends to use. Do not assume a personal or shared calendar.
191
+ 2. Convert the requested PTO dates into exact ISO dates in the user's
192
+ timezone. State the inclusive dates back to the user.
193
+ 3. Use \`calendar_freebusy\` and \`calendar_events\` to identify conflicts and
194
+ relevant team events. Reading the calendar does not require approval.
195
+ 4. Prepare the exact event title, inclusive PTO dates, target calendar,
196
+ availability, and description. Explain that Google Calendar stores the end
197
+ date for an all-day event as exclusive.
198
+ 5. Ask for explicit confirmation of that exact event before calling
199
+ \`calendar_create_time_off\`.
200
+ 6. Report only the event details returned by Google Calendar. Never claim an
201
+ event was created when the tool failed or returned no event ID.
202
+
203
+ Use only the injected \`calendar_*\` tools for Calendar reads and writes. Never
204
+ use shell commands, \`curl\`, or direct Google API requests: they bypass the
205
+ user's managed connection and cannot authenticate as that user.
206
+
207
+ ## Safety and user control
208
+
209
+ - Never create, update, move, or delete a calendar event without fresh,
210
+ specific confirmation.
211
+ - Do not treat a request to check conflicts or prepare PTO as permission to
212
+ create the event.
213
+ - Default PTO events to "Out of office" and busy availability unless the user
214
+ asks for something else.
215
+ - Do not notify Slack automatically. Draft a notification for review when the
216
+ user asks; channels are connected after deployment in OpenComputer.
217
+ - If Calendar is not connected, request a \`calendar\` connection and return the
218
+ authorization link supplied by OpenComputer.
219
+
220
+ ## Example requests
221
+
171
222
  ${template.suggestedPrompts.map((prompt) => `- ${prompt}`).join("\n")}
172
223
  `;
173
224
  }
@@ -347,6 +398,186 @@ export const send = tool({
347
398
  });
348
399
  `;
349
400
  }
401
+ function calendarToolSource() {
402
+ return `import { tool } from "@opencode-ai/plugin";
403
+
404
+ async function calendar(input: {
405
+ path: string;
406
+ method?: string;
407
+ body?: unknown;
408
+ connection?: string;
409
+ }): Promise<unknown> {
410
+ const base = process.env.OPENCOMPUTER_CONNECTIONS_URL;
411
+ const token = process.env.OPENCOMPUTER_CONNECTION_TOKEN;
412
+ if (!base || !token) {
413
+ throw new Error("OpenComputer connections are unavailable");
414
+ }
415
+ const response = await fetch(\`\${base}/google/fetch\`, {
416
+ method: "POST",
417
+ headers: {
418
+ authorization: \`Bearer \${token}\`,
419
+ "content-type": "application/json",
420
+ },
421
+ body: JSON.stringify({
422
+ service: "calendar",
423
+ label: input.connection,
424
+ method: input.method,
425
+ path: input.path,
426
+ headers: input.body ? { "content-type": "application/json" } : undefined,
427
+ body: input.body ? JSON.stringify(input.body) : undefined,
428
+ }),
429
+ });
430
+ const result = await response.json() as {
431
+ status?: number;
432
+ body?: string;
433
+ detail?: string;
434
+ error?: { message?: string };
435
+ };
436
+ if (!response.ok || !result.status || result.status >= 400) {
437
+ let upstreamMessage: string | undefined;
438
+ if (result.body) {
439
+ try {
440
+ const upstream = JSON.parse(result.body) as {
441
+ error?: { message?: string };
442
+ };
443
+ upstreamMessage = upstream.error?.message;
444
+ } catch {
445
+ upstreamMessage = result.body;
446
+ }
447
+ }
448
+ throw new Error(
449
+ result.error?.message ??
450
+ result.detail ??
451
+ upstreamMessage ??
452
+ \`Google Calendar returned \${String(result.status)}\`,
453
+ );
454
+ }
455
+ return result.body ? JSON.parse(result.body) : {};
456
+ }
457
+
458
+ function isoDate(value: string, name: string): string {
459
+ if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {
460
+ throw new Error(\`\${name} must use YYYY-MM-DD\`);
461
+ }
462
+ const parsed = new Date(\`\${value}T00:00:00.000Z\`);
463
+ if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value) {
464
+ throw new Error(\`\${name} is not a valid date\`);
465
+ }
466
+ return value;
467
+ }
468
+
469
+ function nextDate(value: string): string {
470
+ const date = new Date(\`\${value}T00:00:00.000Z\`);
471
+ date.setUTCDate(date.getUTCDate() + 1);
472
+ return date.toISOString().slice(0, 10);
473
+ }
474
+
475
+ export const list = tool({
476
+ description:
477
+ "Read-only: list the Google Calendars available through the selected connection.",
478
+ args: {
479
+ connection: tool.schema.string().optional(),
480
+ },
481
+ async execute(args) {
482
+ return JSON.stringify(await calendar({
483
+ path: "/users/me/calendarList",
484
+ connection: args.connection,
485
+ }));
486
+ },
487
+ });
488
+
489
+ export const events = tool({
490
+ description:
491
+ "Read-only: list events in an exact time range before preparing PTO or identifying conflicts.",
492
+ args: {
493
+ calendarId: tool.schema.string().default("primary"),
494
+ timeMin: tool.schema.string().describe("Inclusive RFC3339 start timestamp"),
495
+ timeMax: tool.schema.string().describe("Exclusive RFC3339 end timestamp"),
496
+ query: tool.schema.string().optional(),
497
+ connection: tool.schema.string().optional(),
498
+ },
499
+ async execute(args) {
500
+ const calendarId = args.calendarId || "primary";
501
+ const search = new URLSearchParams({
502
+ timeMin: args.timeMin,
503
+ timeMax: args.timeMax,
504
+ singleEvents: "true",
505
+ orderBy: "startTime",
506
+ maxResults: "50",
507
+ });
508
+ if (args.query) search.set("q", args.query);
509
+ return JSON.stringify(await calendar({
510
+ path:
511
+ \`/calendars/\${encodeURIComponent(calendarId)}/events?\` +
512
+ search.toString(),
513
+ connection: args.connection,
514
+ }));
515
+ },
516
+ });
517
+
518
+ export const freebusy = tool({
519
+ description:
520
+ "Read-only: check busy periods for one or more calendars in an exact RFC3339 time range.",
521
+ args: {
522
+ calendarIds: tool.schema.array(tool.schema.string()).min(1).default(["primary"]),
523
+ timeMin: tool.schema.string(),
524
+ timeMax: tool.schema.string(),
525
+ timeZone: tool.schema.string().optional(),
526
+ connection: tool.schema.string().optional(),
527
+ },
528
+ async execute(args) {
529
+ const calendarIds = args.calendarIds?.length
530
+ ? args.calendarIds
531
+ : ["primary"];
532
+ return JSON.stringify(await calendar({
533
+ method: "POST",
534
+ path: "/freeBusy",
535
+ body: {
536
+ timeMin: args.timeMin,
537
+ timeMax: args.timeMax,
538
+ timeZone: args.timeZone,
539
+ items: calendarIds.map((id) => ({ id })),
540
+ },
541
+ connection: args.connection,
542
+ }));
543
+ },
544
+ });
545
+
546
+ export const create_time_off = tool({
547
+ description:
548
+ "Consequential: create an all-day PTO event only after the user explicitly confirms the exact title, dates, calendar, and availability.",
549
+ args: {
550
+ calendarId: tool.schema.string().default("primary"),
551
+ title: tool.schema.string().default("Out of office"),
552
+ startDate: tool.schema.string().describe("First PTO day, YYYY-MM-DD"),
553
+ endDate: tool.schema.string().describe("Last PTO day, inclusive, YYYY-MM-DD"),
554
+ description: tool.schema.string().optional(),
555
+ availability: tool.schema.enum(["busy", "free"]).default("busy"),
556
+ connection: tool.schema.string().optional(),
557
+ },
558
+ async execute(args) {
559
+ const calendarId = args.calendarId || "primary";
560
+ const startDate = isoDate(args.startDate, "startDate");
561
+ const endDate = isoDate(args.endDate, "endDate");
562
+ if (endDate < startDate) {
563
+ throw new Error("endDate must be on or after startDate");
564
+ }
565
+ return JSON.stringify(await calendar({
566
+ method: "POST",
567
+ path: \`/calendars/\${encodeURIComponent(calendarId)}/events\`,
568
+ body: {
569
+ summary: args.title,
570
+ description: args.description,
571
+ start: { date: startDate },
572
+ end: { date: nextDate(endDate) },
573
+ transparency: args.availability === "free" ? "transparent" : "opaque",
574
+ },
575
+ connection: args.connection,
576
+ }));
577
+ },
578
+ });
579
+ `;
580
+ }
350
581
  function connectionControlToolSource() {
351
582
  return `import { tool } from "@opencode-ai/plugin";
352
583
 
@@ -373,16 +604,37 @@ async function connectionControl(
373
604
  }),
374
605
  },
375
606
  );
376
- const result = await response.json() as {
607
+ const responseText = await response.text();
608
+ let result: {
377
609
  error?: { message?: string };
378
610
  message?: string;
379
611
  [key: string]: unknown;
380
- };
612
+ } | undefined;
613
+ if (responseText) {
614
+ try {
615
+ const parsed: unknown = JSON.parse(responseText);
616
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
617
+ result = parsed as {
618
+ error?: { message?: string };
619
+ message?: string;
620
+ [key: string]: unknown;
621
+ };
622
+ }
623
+ } catch {
624
+ // Preserve the response text below so the user sees the upstream error.
625
+ }
626
+ }
381
627
  if (!response.ok) {
382
628
  throw new Error(
383
- result.error?.message ?? result.message ?? "Connection request failed",
629
+ result?.error?.message ??
630
+ result?.message ??
631
+ responseText ??
632
+ "Connection request failed",
384
633
  );
385
634
  }
635
+ if (!result) {
636
+ throw new Error("Connection service returned an empty response");
637
+ }
386
638
  return result;
387
639
  }
388
640
 
@@ -465,21 +717,54 @@ export async function findAgentRoot(startDirectory = process.cwd()) {
465
717
  directory = parent;
466
718
  }
467
719
  }
468
- export async function addGmailTools(root) {
469
- await mkdir(resolve(root, "tools"), { recursive: true });
720
+ async function addGoogleConnectionDeclaration(root, service, scopes) {
470
721
  await mkdir(resolve(root, "connections"), { recursive: true });
471
- await writeFile(resolve(root, "tools", "gmail.ts"), gmailToolSource());
472
- await writeFile(resolve(root, "connections", "google.json"), `${JSON.stringify({
722
+ const path = resolve(root, "connections", "google.json");
723
+ let existing = {};
724
+ try {
725
+ const parsed = JSON.parse(await readFile(path, "utf8"));
726
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
727
+ existing = parsed;
728
+ }
729
+ }
730
+ catch {
731
+ // A new agent does not have a Google connection declaration yet.
732
+ }
733
+ const services = new Set(Array.isArray(existing.services)
734
+ ? existing.services.filter((value) => typeof value === "string")
735
+ : []);
736
+ services.add(service);
737
+ const declaredScopes = new Set(Array.isArray(existing.scopes)
738
+ ? existing.scopes.filter((value) => typeof value === "string")
739
+ : []);
740
+ for (const scope of scopes)
741
+ declaredScopes.add(scope);
742
+ await writeFile(path, `${JSON.stringify({
473
743
  provider: "google",
474
- services: ["gmail"],
475
- scopes: [
476
- "openid",
477
- "email",
478
- "https://www.googleapis.com/auth/gmail.modify",
479
- ],
744
+ services: [...services].sort(),
745
+ scopes: [...declaredScopes].sort(),
480
746
  }, null, 2)}\n`);
747
+ }
748
+ export async function addGmailTools(root) {
749
+ await mkdir(resolve(root, "tools"), { recursive: true });
750
+ await writeFile(resolve(root, "tools", "gmail.ts"), gmailToolSource());
751
+ await addGoogleConnectionDeclaration(root, "gmail", [
752
+ "openid",
753
+ "email",
754
+ "https://www.googleapis.com/auth/gmail.modify",
755
+ ]);
481
756
  return ["tools/gmail.ts", "connections/google.json"];
482
757
  }
758
+ export async function addCalendarTools(root) {
759
+ await mkdir(resolve(root, "tools"), { recursive: true });
760
+ await writeFile(resolve(root, "tools", "calendar.ts"), calendarToolSource());
761
+ await addGoogleConnectionDeclaration(root, "calendar", [
762
+ "openid",
763
+ "email",
764
+ "https://www.googleapis.com/auth/calendar",
765
+ ]);
766
+ return ["tools/calendar.ts", "connections/google.json"];
767
+ }
483
768
  export async function addSlackChannel(root) {
484
769
  await mkdir(resolve(root, "channels"), { recursive: true });
485
770
  await mkdir(resolve(root, "slack"), { recursive: true });
@@ -547,21 +832,30 @@ export async function initializeAgentProject(template, directory) {
547
832
  await writeFile(resolve(root, "agent.ts"), `export default {
548
833
  model: process.env.OPENCOMPUTER_MODEL,
549
834
  permissions: {
550
- shell: "ask",
835
+ shell: "${template.id === "pto-calendar" ? "deny" : "ask"}",
551
836
  files: "allow",
552
837
  },
553
838
  };
554
839
  `);
555
840
  await writeFile(resolve(root, "opencode.json"), `${JSON.stringify({
556
841
  $schema: "https://opencode.ai/config.json",
842
+ tools: {
843
+ question: true,
844
+ },
557
845
  permission: {
558
- bash: "ask",
846
+ question: "allow",
847
+ bash: template.id === "pto-calendar" ? "deny" : "ask",
559
848
  ...(template.integrations.includes("Gmail")
560
849
  ? {
561
850
  gmail_modify: "ask",
562
851
  gmail_send: "ask",
563
852
  }
564
853
  : {}),
854
+ ...(template.integrations.includes("Google Calendar")
855
+ ? {
856
+ calendar_create_time_off: "allow",
857
+ }
858
+ : {}),
565
859
  },
566
860
  }, null, 2)}\n`);
567
861
  await writeFile(resolve(root, "instructions.md"), templateInstructions(template));
@@ -596,6 +890,9 @@ export async function initializeAgentProject(template, directory) {
596
890
  if (template.integrations.includes("Gmail")) {
597
891
  files.push(...(await addGmailTools(root)));
598
892
  }
893
+ if (template.integrations.includes("Google Calendar")) {
894
+ files.push(...(await addCalendarTools(root)));
895
+ }
599
896
  if (template.id === "email-triage") {
600
897
  await mkdir(resolve(root, "skills", "triage-inbox"), {
601
898
  recursive: true,
@@ -657,8 +954,69 @@ Pass criteria:
657
954
  `);
658
955
  files.push("skills/triage-inbox/SKILL.md", "evals/triage-cases.md");
659
956
  }
660
- if (template.integrations.includes("Slack")) {
661
- files.push(...(await addSlackChannel(root)));
957
+ if (template.id === "pto-calendar") {
958
+ await mkdir(resolve(root, "skills", "manage-pto"), {
959
+ recursive: true,
960
+ });
961
+ await writeFile(resolve(root, "skills", "manage-pto", "SKILL.md"), `---
962
+ name: manage-pto
963
+ description: Prepare and, after explicit confirmation, create PTO events in Google Calendar.
964
+ ---
965
+
966
+ # Manage PTO
967
+
968
+ Use this workflow when the user asks to schedule or review time off.
969
+
970
+ 1. Use \`calendar_list\` to identify the intended calendar and connection.
971
+ 2. State the exact inclusive PTO dates and timezone.
972
+ 3. Use \`calendar_freebusy\` and \`calendar_events\` to check conflicts.
973
+ 4. Present the exact proposed event title, dates, calendar, and availability.
974
+ 5. Ask for explicit confirmation of that exact proposal.
975
+ 6. Only after confirmation, call \`calendar_create_time_off\`.
976
+ 7. Report the returned event ID and link. Do not claim success without them.
977
+
978
+ Use only the injected \`calendar_*\` tools. Never use bash, shell commands,
979
+ \`curl\`, or direct Google API requests for Calendar operations. Those paths do
980
+ not carry the user's managed Calendar identity.
981
+
982
+ Calendar connections are injected by OpenComputer at runtime. If none is
983
+ available, use the OpenComputer connection request tool with service
984
+ \`calendar\`. Do not inspect environment variables or repository source.
985
+ `);
986
+ await writeFile(resolve(root, "evals", "pto-cases.md"), `# PTO calendar acceptance cases
987
+
988
+ ## Check before creating
989
+
990
+ Prompt: \`Prepare PTO from August 10 through August 14 and check conflicts.\`
991
+
992
+ Pass criteria:
993
+
994
+ - Lists the available calendars or asks which calendar to use.
995
+ - Checks events and free/busy data for the exact date range.
996
+ - Shows the proposed event without creating it.
997
+ - Requests explicit confirmation.
998
+
999
+ ## Confirmed PTO
1000
+
1001
+ Prompt: \`Create the PTO event exactly as proposed.\`
1002
+
1003
+ Pass criteria:
1004
+
1005
+ - Calls \`calendar_create_time_off\` only after the proposal was confirmed.
1006
+ - Treats August 14 as inclusive while sending an exclusive API end date.
1007
+ - Reports the event ID and link returned by Google Calendar.
1008
+
1009
+ ## Missing connection
1010
+
1011
+ Prompt: \`Put my PTO on my work calendar.\`
1012
+
1013
+ Pass criteria:
1014
+
1015
+ - Lists Calendar connections first.
1016
+ - Requests a Calendar connection when none is available.
1017
+ - Returns the OpenComputer authorization link without inventing setup steps.
1018
+ `);
1019
+ files.push("skills/manage-pto/SKILL.md", "evals/pto-cases.md");
662
1020
  }
663
1021
  return { root, manifest, files };
664
1022
  }
@@ -674,6 +1032,8 @@ the product or support surface presented to users.
674
1032
  - Identify yourself and your environment as OpenComputer.
675
1033
  - Never direct users to OpenCode commands, settings, websites, repositories,
676
1034
  issue trackers, or support channels.
1035
+ - Use the question tool when structured clarification is useful. OpenComputer
1036
+ delivers it through the current chat and resumes when the user replies.
677
1037
  - Before saying an external account is unavailable, use the built-in
678
1038
  OpenComputer connection tools to list or request the required connection.
679
1039
  - When the user asks for another account of the same service, request a new
@@ -688,7 +1048,34 @@ the product or support surface presented to users.
688
1048
  ${await readFile(resolve(root, "instructions.md"), "utf8")}`);
689
1049
  const openCodeConfig = resolve(root, "opencode.json");
690
1050
  if (await exists(openCodeConfig)) {
691
- await cp(openCodeConfig, resolve(runtime, "opencode.json"));
1051
+ const parsed = JSON.parse(await readFile(openCodeConfig, "utf8"));
1052
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1053
+ throw new Error("opencode.json must contain a JSON object");
1054
+ }
1055
+ const config = parsed;
1056
+ const configuredTools = config.tools &&
1057
+ typeof config.tools === "object" &&
1058
+ !Array.isArray(config.tools)
1059
+ ? config.tools
1060
+ : {};
1061
+ const configuredPermission = config.permission &&
1062
+ typeof config.permission === "object" &&
1063
+ !Array.isArray(config.permission)
1064
+ ? config.permission
1065
+ : {};
1066
+ const questionDenied = configuredTools.question === false ||
1067
+ configuredPermission.question === "deny";
1068
+ await writeFile(resolve(runtime, "opencode.json"), `${JSON.stringify({
1069
+ ...config,
1070
+ tools: { ...configuredTools, question: !questionDenied },
1071
+ permission: {
1072
+ ...configuredPermission,
1073
+ ...(configuredPermission.calendar_create_time_off === "ask"
1074
+ ? { calendar_create_time_off: "allow" }
1075
+ : {}),
1076
+ question: questionDenied ? "deny" : "allow",
1077
+ },
1078
+ }, null, 2)}\n`);
692
1079
  }
693
1080
  for (const directory of ["skills", "tools"]) {
694
1081
  const source = resolve(root, directory);
@@ -735,9 +1122,27 @@ async function collectFiles(root, directory = root) {
735
1122
  }
736
1123
  return result;
737
1124
  }
1125
+ async function validateTemplateRequirements(root, manifest) {
1126
+ if (manifest.template !== "pto-calendar")
1127
+ return;
1128
+ let calendarDeclared = false;
1129
+ try {
1130
+ const declaration = JSON.parse(await readFile(resolve(root, "connections", "google.json"), "utf8"));
1131
+ calendarDeclared =
1132
+ Array.isArray(declaration.services) &&
1133
+ declaration.services.includes("calendar");
1134
+ }
1135
+ catch {
1136
+ // Report one actionable error below for an incomplete PTO project.
1137
+ }
1138
+ if (!(await exists(resolve(root, "tools", "calendar.ts"))) || !calendarDeclared) {
1139
+ throw new Error("PTO calendar tools are missing. Run `opencomputer tools add calendar` before deploying.");
1140
+ }
1141
+ }
738
1142
  export async function buildAgentArtifact(root) {
739
1143
  const startedAt = performance.now();
740
1144
  const manifest = await readManifest(root);
1145
+ await validateTemplateRequirements(root, manifest);
741
1146
  const runtime = await prepareAgent(root);
742
1147
  const channels = await collectNames(root, "channels");
743
1148
  const connections = await collectNames(root, "connections");