@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/README.md +20 -99
- package/dist/api.d.ts +21 -3
- package/dist/api.js +18 -6
- package/dist/api.js.map +1 -1
- package/dist/commands.js +45 -38
- package/dist/commands.js.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/local.js +23 -51
- package/dist/local.js.map +1 -1
- package/dist/local.test.js +1 -1
- package/dist/local.test.js.map +1 -1
- package/dist/project.d.ts +13 -1
- package/dist/project.js +857 -4
- package/dist/project.js.map +1 -1
- package/dist/project.test.js +90 -2
- package/dist/project.test.js.map +1 -1
- package/dist/slack.js +2 -6
- package/dist/slack.js.map +1 -1
- package/dist/slack.test.js +4 -2
- package/dist/slack.test.js.map +1 -1
- package/package.json +1 -1
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 = [
|
|
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
|
|
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"))) ||
|
|
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
|
}
|