@opencomputer/cli 0.3.12 → 0.4.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.
package/dist/project.js CHANGED
@@ -56,6 +56,14 @@ async function prepareInitializationTarget(root, template) {
56
56
  "package.json",
57
57
  "agent.ts",
58
58
  "instructions.md",
59
+ ...(template.id === "pr-review-readiness"
60
+ ? [
61
+ "tools/github.ts",
62
+ "connections/github.json",
63
+ "skills/review-pr/SKILL.md",
64
+ "evals/pr-review-cases.md",
65
+ ]
66
+ : []),
59
67
  ...(template.integrations.includes("Gmail")
60
68
  ? ["tools/gmail.ts", "connections/google.json"]
61
69
  : []),
@@ -80,7 +88,13 @@ async function prepareInitializationTarget(root, template) {
80
88
  }
81
89
  async function updateGitignore(root) {
82
90
  const path = resolve(root, ".gitignore");
83
- const required = ["node_modules/", ".opencomputer/", ".env"];
91
+ const required = [
92
+ "node_modules/",
93
+ "dist/",
94
+ ".opencomputer/",
95
+ ".env",
96
+ ".env.local",
97
+ ];
84
98
  let existing = "";
85
99
  try {
86
100
  existing = await readFile(path, "utf8");
@@ -219,6 +233,63 @@ user's managed connection and cannot authenticate as that user.
219
233
 
220
234
  ## Example requests
221
235
 
236
+ ${template.suggestedPrompts.map((prompt) => `- ${prompt}`).join("\n")}
237
+ `;
238
+ }
239
+ if (template.id === "pr-review-readiness") {
240
+ return `# ${template.name}
241
+
242
+ You are a code-review readiness agent. Decide whether a connected GitHub pull
243
+ request is ready to consume a human reviewer's attention.
244
+
245
+ ${template.description}
246
+
247
+ ## Required workflow
248
+
249
+ 1. Require an exact GitHub pull request URL and a connected GitHub account with
250
+ access to its repository.
251
+ 2. Call the \`github_pr_context\` tool. Record the current head SHA and treat
252
+ every conclusion as applying only to that SHA.
253
+ 3. Read every returned issue comment, submitted review, inline review comment,
254
+ changed-file record, and available diff hunk. Group inline replies using
255
+ their reply relationships.
256
+ 4. Do not call \`github_checkout\` by default. Use it only when the returned
257
+ diff and file patches are insufficient and surrounding repository guidance
258
+ is needed. It materializes a bounded exact-head working set containing the
259
+ changed files plus relevant instructions and manifests; it does not download
260
+ the entire repository or create a Git remote. Read materialized files from
261
+ the relative \`destination\` returned by the tool.
262
+ 5. Reconcile every substantive prior review finding against the current head.
263
+ A resolved conversation is evidence, not proof: verify the current code.
264
+ 6. Review the complete available change for correctness, security, regressions,
265
+ error handling, test coverage, and repository conventions. Run focused local
266
+ tests when practical, but never claim a test passed unless it completed.
267
+ 7. Return exactly one verdict:
268
+ - \`READY_FOR_HUMAN_REVIEW\`: no known blocking issue remains and the change
269
+ has adequate validation for a human to review efficiently.
270
+ - \`NOT_READY\`: one or more actionable blocking issues remain.
271
+ - \`NEEDS_INFORMATION\`: required code, diff content, repository access, or
272
+ validation is unavailable.
273
+
274
+ ## Output
275
+
276
+ Lead with the verdict and head SHA. Then provide blocking findings, prior
277
+ review-comment status, validation performed, non-blocking notes, and the
278
+ recommended next action. Cite file paths and lines when evidence allows it.
279
+ Return the complete report in the current OpenComputer session.
280
+
281
+ ## Immutable safety boundary
282
+
283
+ GitHub credentials remain in the OpenComputer control plane. The runtime gets
284
+ only a session-scoped broker token, and the broker permits only allowlisted GET
285
+ requests even though GitHub's classic private-repository OAuth scope is broad.
286
+ Never push, create a branch, approve, merge, request changes, dismiss a review,
287
+ or post/edit/delete a GitHub comment. Never add a Git remote or attempt to
288
+ discover credentials. If the connected account cannot access the PR, return
289
+ \`NEEDS_INFORMATION\` with the exact limitation.
290
+
291
+ ## Example requests
292
+
222
293
  ${template.suggestedPrompts.map((prompt) => `- ${prompt}`).join("\n")}
223
294
  `;
224
295
  }
@@ -578,6 +649,291 @@ export const create_time_off = tool({
578
649
  });
579
650
  `;
580
651
  }
652
+ function githubReviewToolSource() {
653
+ return `import { mkdir, writeFile } from "node:fs/promises";
654
+ import { resolve, sep } from "node:path";
655
+ import { tool } from "@opencode-ai/plugin";
656
+
657
+ const MAX_PAGES = 10;
658
+ const MAX_CHECKOUT_FILES = 100;
659
+ const MAX_CHECKOUT_BYTES = 20 * 1024 * 1024;
660
+
661
+ function parsePullRequestUrl(value: string): {
662
+ repository: string;
663
+ number: number;
664
+ } {
665
+ let url: URL;
666
+ try {
667
+ url = new URL(value);
668
+ } catch {
669
+ throw new Error("pullRequestUrl must be a complete GitHub pull request URL");
670
+ }
671
+ const parts = url.pathname.split("/").filter(Boolean);
672
+ const number = Number(parts[3]);
673
+ if (
674
+ url.protocol !== "https:" ||
675
+ url.hostname.toLowerCase() !== "github.com" ||
676
+ parts.length !== 4 ||
677
+ parts[2] !== "pull" ||
678
+ !Number.isSafeInteger(number) ||
679
+ number < 1 ||
680
+ !/^[A-Za-z0-9_.-]+$/.test(parts[0] ?? "") ||
681
+ !/^[A-Za-z0-9_.-]+$/.test(parts[1] ?? "")
682
+ ) {
683
+ throw new Error("Expected https://github.com/<owner>/<repository>/pull/<number>");
684
+ }
685
+ return { repository: parts[0] + "/" + parts[1], number };
686
+ }
687
+
688
+ async function githubFetch(path: string, accept = "application/vnd.github+json") {
689
+ const base = process.env.OPENCOMPUTER_CONNECTIONS_URL;
690
+ const token = process.env.OPENCOMPUTER_CONNECTION_TOKEN;
691
+ if (!base || !token) {
692
+ throw new Error("OpenComputer GitHub connection is unavailable");
693
+ }
694
+ const response = await fetch(base.replace(/\\\/$/, "") + "/github/fetch", {
695
+ method: "POST",
696
+ headers: {
697
+ authorization: "Bearer " + token,
698
+ "content-type": "application/json",
699
+ },
700
+ body: JSON.stringify({
701
+ service: "github",
702
+ method: "GET",
703
+ path,
704
+ headers: { accept, "x-github-api-version": "2022-11-28" },
705
+ }),
706
+ });
707
+ if (!response.ok) {
708
+ const failure = (await response.json().catch(() => ({}))) as {
709
+ error?: { message?: string };
710
+ };
711
+ throw new Error(failure.error?.message ?? "GitHub connection request failed");
712
+ }
713
+ const result = (await response.json()) as {
714
+ status?: number;
715
+ body?: string;
716
+ };
717
+ if (!result.status || result.status >= 400) {
718
+ throw new Error(result.body ?? "GitHub request failed");
719
+ }
720
+ return result.body ?? "";
721
+ }
722
+
723
+ async function githubJson(path: string): Promise<Record<string, unknown>> {
724
+ return JSON.parse(await githubFetch(path)) as Record<string, unknown>;
725
+ }
726
+
727
+ async function githubPages(path: string): Promise<{
728
+ items: Record<string, unknown>[];
729
+ truncated: boolean;
730
+ }> {
731
+ const items: Record<string, unknown>[] = [];
732
+ let lastPageWasFull = false;
733
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
734
+ const separator = path.includes("?") ? "&" : "?";
735
+ const batch = JSON.parse(
736
+ await githubFetch(path + separator + "per_page=100&page=" + String(page)),
737
+ ) as Record<string, unknown>[];
738
+ items.push(...batch);
739
+ lastPageWasFull = batch.length === 100;
740
+ if (!lastPageWasFull) break;
741
+ }
742
+ return { items, truncated: lastPageWasFull };
743
+ }
744
+
745
+ export const pr_context = tool({
746
+ description:
747
+ "Read-only: fetch an accessible GitHub PR, all available issue comments, reviews, inline review comments, changed files, and the full available diff through the connection broker.",
748
+ args: {
749
+ pullRequestUrl: tool.schema.string(),
750
+ },
751
+ async execute(args) {
752
+ const { repository, number } = parsePullRequestUrl(args.pullRequestUrl);
753
+ const prefix = "/repos/" + repository;
754
+ const [pull, comments, reviews, reviewComments, files] = await Promise.all([
755
+ githubJson(prefix + "/pulls/" + String(number)),
756
+ githubPages(prefix + "/issues/" + String(number) + "/comments"),
757
+ githubPages(prefix + "/pulls/" + String(number) + "/reviews"),
758
+ githubPages(prefix + "/pulls/" + String(number) + "/comments"),
759
+ githubPages(prefix + "/pulls/" + String(number) + "/files"),
760
+ ]);
761
+ let diff: string | undefined;
762
+ try {
763
+ diff = await githubFetch(
764
+ prefix + "/pulls/" + String(number),
765
+ "application/vnd.github.v3.diff",
766
+ );
767
+ } catch {
768
+ // Per-file patches remain available. The completeness marker below tells
769
+ // the reviewer that it must not claim full-diff coverage.
770
+ }
771
+ const head =
772
+ pull.head && typeof pull.head === "object"
773
+ ? (pull.head as Record<string, unknown>)
774
+ : {};
775
+ const base =
776
+ pull.base && typeof pull.base === "object"
777
+ ? (pull.base as Record<string, unknown>)
778
+ : {};
779
+ const maximumDiff = 2_000_000;
780
+ return JSON.stringify({
781
+ repository,
782
+ number,
783
+ url: args.pullRequestUrl,
784
+ pull: {
785
+ title: pull.title,
786
+ body: pull.body,
787
+ state: pull.state,
788
+ draft: pull.draft,
789
+ mergeable: pull.mergeable,
790
+ mergeableState: pull.mergeable_state,
791
+ author: pull.user,
792
+ additions: pull.additions,
793
+ deletions: pull.deletions,
794
+ changedFiles: pull.changed_files,
795
+ head: { ref: head.ref, sha: head.sha },
796
+ base: { ref: base.ref, sha: base.sha },
797
+ },
798
+ comments: comments.items,
799
+ reviews: reviews.items,
800
+ reviewComments: reviewComments.items,
801
+ files: files.items,
802
+ diff: diff?.slice(0, maximumDiff),
803
+ completeness: {
804
+ comments: !comments.truncated,
805
+ reviews: !reviews.truncated,
806
+ reviewComments: !reviewComments.truncated,
807
+ files: !files.truncated,
808
+ diff: Boolean(diff) && (diff?.length ?? 0) <= maximumDiff,
809
+ },
810
+ });
811
+ },
812
+ });
813
+
814
+ export const checkout = tool({
815
+ description:
816
+ "Read-only: materialize changed files plus relevant repository instructions and manifests from the exact PR head into a bounded session-workspace directory, without downloading the whole repository or creating a Git remote. Use the destination returned by this tool for subsequent file reads.",
817
+ args: {
818
+ pullRequestUrl: tool.schema.string(),
819
+ },
820
+ async execute(args, context) {
821
+ const { repository, number } = parsePullRequestUrl(args.pullRequestUrl);
822
+ const pull = await githubJson(
823
+ "/repos/" + repository + "/pulls/" + String(number),
824
+ );
825
+ const head =
826
+ pull.head && typeof pull.head === "object"
827
+ ? (pull.head as Record<string, unknown>)
828
+ : {};
829
+ if (typeof head.sha !== "string" || !/^[a-f0-9]{40}$/i.test(head.sha)) {
830
+ throw new Error("GitHub did not return a valid PR head SHA");
831
+ }
832
+ const workspace = resolve(context.directory);
833
+ const destinationName =
834
+ "github-pr-" + number + "-" + head.sha.slice(0, 12).toLowerCase();
835
+ const destination = resolve(workspace, destinationName);
836
+ if (!destination.startsWith(workspace + sep)) {
837
+ throw new Error("generated checkout destination escaped the workspace");
838
+ }
839
+ const changed = await githubPages(
840
+ "/repos/" + repository + "/pulls/" + String(number) + "/files",
841
+ );
842
+ const changedPaths = new Set(
843
+ changed.items
844
+ .map((file) => file.filename)
845
+ .filter((path): path is string => typeof path === "string"),
846
+ );
847
+ const candidates = new Set(changedPaths);
848
+ const guidanceNames = [
849
+ "AGENTS.md",
850
+ "README.md",
851
+ "CONTRIBUTING.md",
852
+ "package.json",
853
+ "pnpm-workspace.yaml",
854
+ "go.mod",
855
+ "go.work",
856
+ "Cargo.toml",
857
+ "pyproject.toml",
858
+ "requirements.txt",
859
+ ];
860
+ for (const name of guidanceNames) candidates.add(name);
861
+ for (const path of changedPaths) {
862
+ const parts = path.split("/");
863
+ for (let depth = 1; depth < parts.length; depth += 1) {
864
+ const directory = parts.slice(0, depth).join("/");
865
+ for (const name of ["AGENTS.md", "README.md", "package.json"]) {
866
+ candidates.add(directory + "/" + name);
867
+ }
868
+ }
869
+ }
870
+ const requested = [...candidates].slice(0, MAX_CHECKOUT_FILES);
871
+ await mkdir(destination, { recursive: true });
872
+ const materialized: string[] = [];
873
+ const missingChanged: string[] = [];
874
+ let totalBytes = 0;
875
+ let limitExceeded = false;
876
+ for (let offset = 0; offset < requested.length; offset += 8) {
877
+ const batch = requested.slice(offset, offset + 8);
878
+ await Promise.all(
879
+ batch.map(async (path) => {
880
+ const encodedPath = path.split("/").map(encodeURIComponent).join("/");
881
+ try {
882
+ const file = await githubJson(
883
+ "/repos/" + repository + "/contents/" + encodedPath + "?ref=" + head.sha,
884
+ );
885
+ let content: Buffer;
886
+ if (file.encoding === "base64" && typeof file.content === "string") {
887
+ content = Buffer.from(file.content.replace(/\\s+/g, ""), "base64");
888
+ } else if (typeof file.sha === "string" && /^[a-f0-9]{40}$/i.test(file.sha)) {
889
+ const blob = await githubJson(
890
+ "/repos/" + repository + "/git/blobs/" + file.sha,
891
+ );
892
+ if (blob.encoding !== "base64" || typeof blob.content !== "string") {
893
+ throw new Error("unsupported GitHub content encoding");
894
+ }
895
+ content = Buffer.from(blob.content.replace(/\\s+/g, ""), "base64");
896
+ } else {
897
+ throw new Error("GitHub did not return file content");
898
+ }
899
+ totalBytes += content.byteLength;
900
+ if (totalBytes > MAX_CHECKOUT_BYTES) {
901
+ limitExceeded = true;
902
+ throw new Error("bounded checkout exceeds 20 MiB");
903
+ }
904
+ const output = resolve(destination, path);
905
+ if (!output.startsWith(destination + sep)) {
906
+ throw new Error("GitHub returned an unsafe repository path");
907
+ }
908
+ await mkdir(resolve(output, ".."), { recursive: true });
909
+ await writeFile(output, content);
910
+ materialized.push(path);
911
+ } catch (error) {
912
+ if (changedPaths.has(path)) missingChanged.push(path);
913
+ }
914
+ }),
915
+ );
916
+ }
917
+ return JSON.stringify({
918
+ repository,
919
+ pullRequestNumber: number,
920
+ headSha: head.sha,
921
+ destination: destinationName,
922
+ materialized: materialized.sort(),
923
+ bytes: totalBytes,
924
+ missingChanged: missingChanged.sort(),
925
+ complete:
926
+ !changed.truncated &&
927
+ candidates.size <= MAX_CHECKOUT_FILES &&
928
+ !limitExceeded &&
929
+ missingChanged.length === 0,
930
+ scope: "changed files plus relevant instructions and manifests",
931
+ remoteConfigured: false,
932
+ });
933
+ },
934
+ });
935
+ `;
936
+ }
581
937
  function connectionControlToolSource() {
582
938
  return `import { tool } from "@opencode-ai/plugin";
583
939
 
@@ -651,7 +1007,7 @@ export const request = tool({
651
1007
  description:
652
1008
  "Ask the current user to connect an account. Use gmail for an email account. Set newAccount=true when the user asks for another account of the same service. In a messaging channel OpenComputer privately sends the authorization link to that user; otherwise the result includes the link.",
653
1009
  args: {
654
- service: tool.schema.enum(["gmail", "calendar", "drive", "sheets"]),
1010
+ service: tool.schema.enum(["gmail", "calendar", "drive", "sheets", "github"]),
655
1011
  label: tool.schema.string().optional(),
656
1012
  newAccount: tool.schema.boolean().optional(),
657
1013
  },
@@ -707,10 +1063,39 @@ export async function readManifest(root) {
707
1063
  export async function findAgentRoot(startDirectory = process.cwd()) {
708
1064
  let directory = resolve(startDirectory);
709
1065
  for (;;) {
1066
+ const nested = resolve(directory, "opencomputer");
710
1067
  if ((await exists(resolve(directory, "opencomputer.toml"))) &&
711
1068
  (await exists(resolve(directory, "instructions.md")))) {
712
1069
  return directory;
713
1070
  }
1071
+ if ((await exists(resolve(nested, "opencomputer.toml"))) &&
1072
+ (await exists(resolve(nested, "instructions.md")))) {
1073
+ return nested;
1074
+ }
1075
+ for (const agentsDirectory of [
1076
+ resolve(directory, "opencomputer", "agents"),
1077
+ resolve(directory, "agents"),
1078
+ ]) {
1079
+ if (!(await exists(agentsDirectory)))
1080
+ continue;
1081
+ const detected = [];
1082
+ for (const entry of await readdir(agentsDirectory, {
1083
+ withFileTypes: true,
1084
+ })) {
1085
+ if (!entry.isDirectory())
1086
+ continue;
1087
+ const agent = resolve(agentsDirectory, entry.name);
1088
+ if ((await exists(resolve(agent, "opencomputer.toml"))) &&
1089
+ (await exists(resolve(agent, "instructions.md")))) {
1090
+ detected.push(agent);
1091
+ }
1092
+ }
1093
+ if (detected.length === 1)
1094
+ return detected[0];
1095
+ if (detected.length > 1) {
1096
+ throw new Error("This project has multiple agents. Select an agent when starting dev mode.");
1097
+ }
1098
+ }
714
1099
  const parent = dirname(directory);
715
1100
  if (parent === directory)
716
1101
  return undefined;
@@ -765,6 +1150,13 @@ export async function addCalendarTools(root) {
765
1150
  ]);
766
1151
  return ["tools/calendar.ts", "connections/google.json"];
767
1152
  }
1153
+ export async function addGithubReviewTools(root) {
1154
+ await mkdir(resolve(root, "tools"), { recursive: true });
1155
+ await mkdir(resolve(root, "connections"), { recursive: true });
1156
+ await writeFile(resolve(root, "tools", "github.ts"), githubReviewToolSource());
1157
+ await writeFile(resolve(root, "connections", "github.json"), `${JSON.stringify({ provider: "github", services: ["github"], scopes: ["repo"] }, null, 2)}\n`);
1158
+ return ["tools/github.ts", "connections/github.json"];
1159
+ }
768
1160
  export async function addSlackChannel(root) {
769
1161
  await mkdir(resolve(root, "channels"), { recursive: true });
770
1162
  await mkdir(resolve(root, "slack"), { recursive: true });
@@ -804,7 +1196,7 @@ export async function addSlackChannel(root) {
804
1196
  }, null, 2)}\n`);
805
1197
  return ["channels/slack.ts", "slack/manifest.json"];
806
1198
  }
807
- export async function initializeAgentProject(template, directory) {
1199
+ export async function initializeTemplateAgentProject(template, directory) {
808
1200
  const root = resolve(directory);
809
1201
  await prepareInitializationTarget(root, template);
810
1202
  for (const path of [
@@ -893,6 +1285,66 @@ export async function initializeAgentProject(template, directory) {
893
1285
  if (template.integrations.includes("Google Calendar")) {
894
1286
  files.push(...(await addCalendarTools(root)));
895
1287
  }
1288
+ if (template.id === "pr-review-readiness") {
1289
+ files.push(...(await addGithubReviewTools(root)));
1290
+ await mkdir(resolve(root, "skills", "review-pr"), { recursive: true });
1291
+ await writeFile(resolve(root, "skills", "review-pr", "SKILL.md"), `---
1292
+ name: review-pr
1293
+ description: Decide whether a connected GitHub pull request is ready for human review.
1294
+ ---
1295
+
1296
+ # Review PR readiness
1297
+
1298
+ 1. Call \`github_pr_context\` with the exact PR URL.
1299
+ 2. Record the returned head SHA and completeness markers.
1300
+ 3. Read all prior review feedback and map it to the current code.
1301
+ 4. Call \`github_checkout\` only when the diff is insufficient and surrounding
1302
+ repository instructions are required. It creates a bounded exact-head
1303
+ working set and no Git remote. Read files from its returned relative
1304
+ \`destination\`.
1305
+ 5. Verify every prior finding against the current head.
1306
+ 6. Return \`NEEDS_INFORMATION\` if any context required for a sound conclusion
1307
+ is unavailable or marked incomplete.
1308
+ 7. Otherwise return \`READY_FOR_HUMAN_REVIEW\` or \`NOT_READY\` using the rubric
1309
+ in the agent instructions.
1310
+
1311
+ Never use GitHub write APIs, add a Git remote, or search for credentials.
1312
+ `);
1313
+ await writeFile(resolve(root, "evals", "pr-review-cases.md"), `# PR review readiness acceptance cases
1314
+
1315
+ ## Addressed prior feedback
1316
+
1317
+ Prompt: Review a PR whose latest head fixes every earlier blocker.
1318
+
1319
+ Pass criteria:
1320
+
1321
+ - Reads PR metadata, all comment sources, changed files, and the available diff.
1322
+ - Records the exact head SHA and checks response completeness.
1323
+ - Verifies fixes in current code rather than trusting resolved-thread state.
1324
+ - Returns READY_FOR_HUMAN_REVIEW only when no blocker remains.
1325
+ - Performs no GitHub write and creates no Git remote.
1326
+
1327
+ ## Remaining blocker
1328
+
1329
+ Prompt: Review a PR with an unresolved correctness regression.
1330
+
1331
+ Pass criteria:
1332
+
1333
+ - Returns NOT_READY and leads with the concrete blocker.
1334
+ - Cites the affected file and explains the failure mode.
1335
+ - Distinguishes prior-review status from newly discovered findings.
1336
+
1337
+ ## Inaccessible or incomplete PR
1338
+
1339
+ Prompt: Review a PR that the connected account cannot access or whose response has incomplete pagination.
1340
+
1341
+ Pass criteria:
1342
+
1343
+ - Returns NEEDS_INFORMATION rather than guessing readiness.
1344
+ - Identifies the exact access, content, or validation limitation.
1345
+ `);
1346
+ files.push("skills/review-pr/SKILL.md", "evals/pr-review-cases.md");
1347
+ }
896
1348
  if (template.id === "email-triage") {
897
1349
  await mkdir(resolve(root, "skills", "triage-inbox"), {
898
1350
  recursive: true,
@@ -1020,6 +1472,393 @@ Pass criteria:
1020
1472
  }
1021
1473
  return { root, manifest, files };
1022
1474
  }
1475
+ const HELLO_WORLD_TEMPLATE = {
1476
+ id: "hello-world",
1477
+ name: "Hello World",
1478
+ description: "Greet the user, explain that this agent is running live, and answer simple questions clearly.",
1479
+ category: "Getting started",
1480
+ integrations: [],
1481
+ suggestedPrompts: ["Say hello and tell me what you can do."],
1482
+ };
1483
+ export async function assertStarterTarget(directory) {
1484
+ const root = resolve(directory);
1485
+ if (!(await exists(root))) {
1486
+ await mkdir(root, { recursive: true });
1487
+ return;
1488
+ }
1489
+ const reserved = [
1490
+ "opencomputer/project.ts",
1491
+ "opencomputer/agents/hello-world/opencomputer.toml",
1492
+ "package.json",
1493
+ "vite.config.ts",
1494
+ "index.html",
1495
+ "src/App.tsx",
1496
+ "src/main.tsx",
1497
+ ];
1498
+ const conflicts = [];
1499
+ for (const path of reserved) {
1500
+ if (await exists(resolve(root, path)))
1501
+ conflicts.push(path);
1502
+ }
1503
+ if (conflicts.length) {
1504
+ throw new Error(`Target already contains OpenComputer app files: ${conflicts.join(", ")}`);
1505
+ }
1506
+ }
1507
+ export async function initializeAgentProject(directory, project) {
1508
+ const root = resolve(directory);
1509
+ const agentRoot = resolve(root, "opencomputer", "agents", "hello-world");
1510
+ await assertStarterTarget(root);
1511
+ const initialized = await initializeTemplateAgentProject(HELLO_WORLD_TEMPLATE, agentRoot);
1512
+ const manifest = {
1513
+ ...initialized.manifest,
1514
+ ...(project ? { id: project.agentId } : {}),
1515
+ name: "Hello World",
1516
+ };
1517
+ await writeManifest(agentRoot, manifest);
1518
+ await rm(resolve(agentRoot, "package.json"), { force: true });
1519
+ await rm(resolve(agentRoot, ".gitignore"), { force: true });
1520
+ await updateGitignore(root);
1521
+ await mkdir(resolve(root, "src"), { recursive: true });
1522
+ await writeFile(resolve(root, "opencomputer", "project.ts"), `export default {
1523
+ id: ${JSON.stringify(project?.id ?? manifest.id)},
1524
+ name: ${JSON.stringify(project?.name ?? "Hello World")},
1525
+ agents: ["hello-world"],
1526
+ };
1527
+ `);
1528
+ await writeFile(resolve(root, "package.json"), `${JSON.stringify({
1529
+ name: `opencomputer-app-${manifest.id}`,
1530
+ version: "0.1.0",
1531
+ private: true,
1532
+ type: "module",
1533
+ scripts: {
1534
+ dev: "opencomputer dev",
1535
+ "dev:web": "vite",
1536
+ build: "tsc -b && vite build",
1537
+ session: "opencomputer session",
1538
+ deploy: "opencomputer deploy",
1539
+ },
1540
+ dependencies: {
1541
+ react: "^19.2.0",
1542
+ "react-dom": "^19.2.0",
1543
+ },
1544
+ devDependencies: {
1545
+ "@opencomputer/cli": "^0.4.0",
1546
+ "@opencode-ai/plugin": "^1.18.4",
1547
+ "@types/node": "^24.0.0",
1548
+ "@types/react": "^19.2.0",
1549
+ "@types/react-dom": "^19.2.0",
1550
+ "@vitejs/plugin-react": "^6.0.0",
1551
+ "opencode-ai": "1.18.4",
1552
+ typescript: "^5.9.0",
1553
+ vite: "^8.0.0",
1554
+ },
1555
+ }, null, 2)}\n`);
1556
+ await writeFile(resolve(root, "vite.config.ts"), `import { readFileSync } from "node:fs";
1557
+ import { resolve } from "node:path";
1558
+ import react from "@vitejs/plugin-react";
1559
+ import { defineConfig } from "vite";
1560
+
1561
+ function openComputerDev() {
1562
+ try {
1563
+ return JSON.parse(
1564
+ readFileSync(
1565
+ resolve("opencomputer/agents/hello-world/.opencomputer/dev.json"),
1566
+ "utf8",
1567
+ ),
1568
+ ) as { url: string; token: string };
1569
+ } catch {
1570
+ throw new Error(
1571
+ "OpenComputer is not running. Start npm run dev in another terminal first.",
1572
+ );
1573
+ }
1574
+ }
1575
+
1576
+ export default defineConfig(({ command }) => {
1577
+ const dev = command === "serve" ? openComputerDev() : undefined;
1578
+ return {
1579
+ plugins: [react()],
1580
+ ...(dev ? { server: {
1581
+ proxy: {
1582
+ "/api/opencomputer": {
1583
+ target: dev.url,
1584
+ headers: { authorization: \`Bearer \${dev.token}\` },
1585
+ rewrite: (path) => path.replace(/^\\/api\\/opencomputer/, ""),
1586
+ },
1587
+ },
1588
+ } } : {}),
1589
+ };
1590
+ });
1591
+ `);
1592
+ await writeFile(resolve(root, "tsconfig.json"), `${JSON.stringify({
1593
+ compilerOptions: {
1594
+ target: "ES2022",
1595
+ useDefineForClassFields: true,
1596
+ lib: ["ES2022", "DOM", "DOM.Iterable"],
1597
+ allowJs: false,
1598
+ skipLibCheck: true,
1599
+ esModuleInterop: true,
1600
+ allowSyntheticDefaultImports: true,
1601
+ strict: true,
1602
+ forceConsistentCasingInFileNames: true,
1603
+ module: "ESNext",
1604
+ moduleResolution: "Bundler",
1605
+ resolveJsonModule: true,
1606
+ isolatedModules: true,
1607
+ noEmit: true,
1608
+ jsx: "react-jsx",
1609
+ types: ["node", "vite/client"],
1610
+ },
1611
+ include: ["src", "vite.config.ts"],
1612
+ }, null, 2)}\n`);
1613
+ await writeFile(resolve(root, "index.html"), `<!doctype html>
1614
+ <html lang="en">
1615
+ <head>
1616
+ <meta charset="UTF-8" />
1617
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1618
+ <meta name="theme-color" content="#11110f" />
1619
+ <title>Hello World · OpenComputer</title>
1620
+ </head>
1621
+ <body>
1622
+ <div id="root"></div>
1623
+ <script type="module" src="/src/main.tsx"></script>
1624
+ </body>
1625
+ </html>
1626
+ `);
1627
+ await writeFile(resolve(root, "src", "use-agent.ts"), `import { useCallback, useState } from "react";
1628
+
1629
+ export interface AgentMessage {
1630
+ id: string;
1631
+ role: "user" | "assistant";
1632
+ text: string;
1633
+ }
1634
+
1635
+ interface AgentEvent {
1636
+ type: string;
1637
+ data: Record<string, unknown>;
1638
+ }
1639
+
1640
+ export function useAgent() {
1641
+ const [messages, setMessages] = useState<AgentMessage[]>([]);
1642
+ const [sessionId, setSessionId] = useState<string>();
1643
+ const [isRunning, setIsRunning] = useState(false);
1644
+ const [error, setError] = useState<string>();
1645
+
1646
+ const send = useCallback(async (value: string) => {
1647
+ const prompt = value.trim();
1648
+ if (!prompt || isRunning) return;
1649
+ const userMessage: AgentMessage = {
1650
+ id: crypto.randomUUID(),
1651
+ role: "user",
1652
+ text: prompt,
1653
+ };
1654
+ const assistantId = crypto.randomUUID();
1655
+ setMessages((current) => [
1656
+ ...current,
1657
+ userMessage,
1658
+ { id: assistantId, role: "assistant", text: "" },
1659
+ ]);
1660
+ setIsRunning(true);
1661
+ setError(undefined);
1662
+ try {
1663
+ const endpoint = sessionId
1664
+ ? \`/api/opencomputer/sessions/\${encodeURIComponent(sessionId)}\`
1665
+ : "/api/opencomputer/sessions";
1666
+ const response = await fetch(endpoint, {
1667
+ method: "POST",
1668
+ headers: { "content-type": "application/json" },
1669
+ body: JSON.stringify({ prompt }),
1670
+ });
1671
+ if (!response.ok || !response.body) {
1672
+ throw new Error(\`Agent request failed (\${response.status})\`);
1673
+ }
1674
+ const createdSession = response.headers.get("x-opencomputer-session-id");
1675
+ if (createdSession) setSessionId(createdSession);
1676
+ const reader = response.body.getReader();
1677
+ const decoder = new TextDecoder();
1678
+ let buffered = "";
1679
+ let streamed = "";
1680
+ for (;;) {
1681
+ const { done, value } = await reader.read();
1682
+ if (done) break;
1683
+ buffered += decoder.decode(value, { stream: true });
1684
+ const lines = buffered.split("\\n");
1685
+ buffered = lines.pop() ?? "";
1686
+ for (const line of lines) {
1687
+ if (!line.trim()) continue;
1688
+ const event = JSON.parse(line) as AgentEvent;
1689
+ if (event.type === "message.delta") {
1690
+ streamed += String(event.data.text ?? "");
1691
+ setMessages((current) =>
1692
+ current.map((message) =>
1693
+ message.id === assistantId
1694
+ ? { ...message, text: streamed }
1695
+ : message,
1696
+ ),
1697
+ );
1698
+ } else if (event.type === "message.completed" && !streamed) {
1699
+ const text = String(event.data.text ?? "");
1700
+ setMessages((current) =>
1701
+ current.map((message) =>
1702
+ message.id === assistantId ? { ...message, text } : message,
1703
+ ),
1704
+ );
1705
+ } else if (event.type === "session.failed") {
1706
+ throw new Error(String(event.data.message ?? "Agent failed"));
1707
+ }
1708
+ }
1709
+ }
1710
+ } catch (cause) {
1711
+ const message = cause instanceof Error ? cause.message : String(cause);
1712
+ setError(message);
1713
+ setMessages((current) =>
1714
+ current.map((item) =>
1715
+ item.id === assistantId && !item.text
1716
+ ? { ...item, text: "I couldn't complete that request." }
1717
+ : item,
1718
+ ),
1719
+ );
1720
+ } finally {
1721
+ setIsRunning(false);
1722
+ }
1723
+ }, [isRunning, sessionId]);
1724
+
1725
+ return { messages, send, isRunning, error };
1726
+ }
1727
+ `);
1728
+ await writeFile(resolve(root, "src", "App.tsx"), `import { FormEvent, useState } from "react";
1729
+ import { useAgent } from "./use-agent";
1730
+
1731
+ export default function App() {
1732
+ const [input, setInput] = useState("");
1733
+ const { messages, send, isRunning, error } = useAgent();
1734
+
1735
+ function submit(event: FormEvent) {
1736
+ event.preventDefault();
1737
+ const prompt = input;
1738
+ setInput("");
1739
+ void send(prompt);
1740
+ }
1741
+
1742
+ return (
1743
+ <main>
1744
+ <section className="hero">
1745
+ <span className="eyebrow">OpenComputer</span>
1746
+ <h1>Hello, world.</h1>
1747
+ <p>Your first agent is live. Ask it anything to see the local backend and React app working together.</p>
1748
+ </section>
1749
+
1750
+ <section className="chat" aria-label="Agent conversation">
1751
+ <div className="messages">
1752
+ {messages.length === 0 ? (
1753
+ <button className="suggestion" onClick={() => void send("Say hello and tell me what you can do.")}>
1754
+ Say hello and tell me what you can do →
1755
+ </button>
1756
+ ) : (
1757
+ messages.map((message) => (
1758
+ <article key={message.id} className={message.role}>
1759
+ <strong>{message.role === "user" ? "You" : "Agent"}</strong>
1760
+ <p>{message.text || "Thinking…"}</p>
1761
+ </article>
1762
+ ))
1763
+ )}
1764
+ </div>
1765
+ {error ? <p className="error">{error}</p> : null}
1766
+ <form onSubmit={submit}>
1767
+ <input
1768
+ value={input}
1769
+ onChange={(event) => setInput(event.target.value)}
1770
+ placeholder="Message the hello-world agent…"
1771
+ aria-label="Message"
1772
+ />
1773
+ <button disabled={isRunning || !input.trim()}>
1774
+ {isRunning ? "Running…" : "Send"}
1775
+ </button>
1776
+ </form>
1777
+ </section>
1778
+ </main>
1779
+ );
1780
+ }
1781
+ `);
1782
+ await writeFile(resolve(root, "src", "main.tsx"), `import { StrictMode } from "react";
1783
+ import { createRoot } from "react-dom/client";
1784
+ import App from "./App";
1785
+ import "./styles.css";
1786
+
1787
+ createRoot(document.getElementById("root")!).render(
1788
+ <StrictMode>
1789
+ <App />
1790
+ </StrictMode>,
1791
+ );
1792
+ `);
1793
+ await writeFile(resolve(root, "src", "styles.css"), `@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap");
1794
+
1795
+ :root { color: #1d1c19; background: #f4f1e9; font-family: "DM Sans", sans-serif; }
1796
+ * { box-sizing: border-box; }
1797
+ body { margin: 0; min-width: 320px; min-height: 100vh; }
1798
+ button, input { font: inherit; }
1799
+ main { width: min(760px, calc(100% - 32px)); margin: 0 auto; padding: 12vh 0 48px; }
1800
+ .hero { margin-bottom: 36px; }
1801
+ .eyebrow { color: #706b60; font-size: 12px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; }
1802
+ h1 { margin: 10px 0; font: 400 clamp(48px, 10vw, 84px)/.95 "DM Serif Display", serif; }
1803
+ .hero p { max-width: 580px; color: #625e55; font-size: 18px; line-height: 1.6; }
1804
+ .chat { overflow: hidden; border: 1px solid #d9d4c8; border-radius: 18px; background: rgba(255,255,255,.72); box-shadow: 0 18px 60px rgba(56,48,34,.08); }
1805
+ .messages { display: grid; gap: 14px; min-height: 260px; max-height: 52vh; overflow-y: auto; padding: 24px; }
1806
+ article { max-width: 82%; border-radius: 14px; padding: 12px 14px; }
1807
+ article strong { display: block; margin-bottom: 4px; font-size: 12px; color: #777166; }
1808
+ article p { margin: 0; line-height: 1.55; white-space: pre-wrap; }
1809
+ article.user { justify-self: end; background: #1d1c19; color: white; }
1810
+ article.user strong { color: #bdb7ab; }
1811
+ article.assistant { background: #ece8de; }
1812
+ .suggestion { align-self: center; justify-self: center; border: 1px solid #d9d4c8; border-radius: 999px; background: transparent; padding: 10px 16px; cursor: pointer; }
1813
+ .suggestion:hover { background: #ece8de; }
1814
+ .error { margin: 0 24px 12px; color: #a33a2b; font-size: 14px; }
1815
+ form { display: flex; gap: 10px; border-top: 1px solid #ddd8cc; padding: 14px; background: white; }
1816
+ input { min-width: 0; flex: 1; border: 0; outline: 0; padding: 10px; background: transparent; }
1817
+ form button { border: 0; border-radius: 10px; background: #d85b35; color: white; padding: 10px 18px; font-weight: 600; cursor: pointer; }
1818
+ form button:disabled { cursor: default; opacity: .45; }
1819
+ `);
1820
+ await writeFile(resolve(root, "README.md"), `# Hello World OpenComputer app
1821
+
1822
+ This project keeps agent definitions in \`opencomputer/\` and the React app in
1823
+ \`src/\`.
1824
+
1825
+ Start the agent server in one terminal:
1826
+
1827
+ \`\`\`bash
1828
+ npm run dev
1829
+ \`\`\`
1830
+
1831
+ Then start the React app in another:
1832
+
1833
+ \`\`\`bash
1834
+ npm run dev:web
1835
+ \`\`\`
1836
+ `);
1837
+ const appFiles = [
1838
+ "package.json",
1839
+ "vite.config.ts",
1840
+ "tsconfig.json",
1841
+ "index.html",
1842
+ "README.md",
1843
+ ".gitignore",
1844
+ "src/App.tsx",
1845
+ "src/main.tsx",
1846
+ "src/styles.css",
1847
+ "src/use-agent.ts",
1848
+ ];
1849
+ return {
1850
+ root,
1851
+ agentRoot,
1852
+ manifest,
1853
+ files: [
1854
+ "opencomputer/project.ts",
1855
+ ...initialized.files
1856
+ .filter((path) => path !== "package.json" && path !== ".gitignore")
1857
+ .map((path) => `opencomputer/agents/hello-world/${path}`),
1858
+ ...appFiles,
1859
+ ],
1860
+ };
1861
+ }
1023
1862
  export async function prepareAgent(root) {
1024
1863
  const runtime = resolve(root, ".opencomputer", "runtime");
1025
1864
  await rm(runtime, { recursive: true, force: true });
@@ -1123,6 +1962,19 @@ async function collectFiles(root, directory = root) {
1123
1962
  return result;
1124
1963
  }
1125
1964
  async function validateTemplateRequirements(root, manifest) {
1965
+ if (manifest.template === "pr-review-readiness") {
1966
+ for (const path of [
1967
+ "tools/github.ts",
1968
+ "connections/github.json",
1969
+ "skills/review-pr/SKILL.md",
1970
+ "evals/pr-review-cases.md",
1971
+ ]) {
1972
+ if (!(await exists(resolve(root, path)))) {
1973
+ throw new Error(`PR review readiness file is missing: ${path}`);
1974
+ }
1975
+ }
1976
+ return;
1977
+ }
1126
1978
  if (manifest.template !== "pto-calendar")
1127
1979
  return;
1128
1980
  let calendarDeclared = false;
@@ -1135,7 +1987,8 @@ async function validateTemplateRequirements(root, manifest) {
1135
1987
  catch {
1136
1988
  // Report one actionable error below for an incomplete PTO project.
1137
1989
  }
1138
- if (!(await exists(resolve(root, "tools", "calendar.ts"))) || !calendarDeclared) {
1990
+ if (!(await exists(resolve(root, "tools", "calendar.ts"))) ||
1991
+ !calendarDeclared) {
1139
1992
  throw new Error("PTO calendar tools are missing. Run `opencomputer tools add calendar` before deploying.");
1140
1993
  }
1141
1994
  }