@fraylabs/possible 0.1.10 → 0.3.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/package.json +8 -13
- package/src/bookmarks.mjs +72 -0
- package/src/directory.mjs +37 -0
- package/src/index.mjs +84 -14
- package/src/outcome-commands.mjs +134 -0
- package/src/outcome-format.mjs +201 -0
- package/src/sources.mjs +174 -0
- package/assets/possible/SKILL.md +0 -124
- package/assets/possible/agents/openai.yaml +0 -4
- package/assets/possible/references/packs.md +0 -570
- package/src/init.mjs +0 -150
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fraylabs/possible",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Create, publish, discover, and reuse source-owned AI Outcomes",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
7
7
|
"ai-agents",
|
|
8
8
|
"agent-skills",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
9
|
+
"prompts",
|
|
10
|
+
"ai-outcomes",
|
|
11
|
+
"developer-tools"
|
|
12
12
|
],
|
|
13
13
|
"homepage": "https://possible.sh",
|
|
14
14
|
"repository": {
|
|
@@ -23,15 +23,10 @@
|
|
|
23
23
|
"bin": {
|
|
24
24
|
"possible": "src/index.mjs"
|
|
25
25
|
},
|
|
26
|
-
"files": [
|
|
27
|
-
"assets/possible",
|
|
28
|
-
"src"
|
|
29
|
-
],
|
|
26
|
+
"files": ["src"],
|
|
30
27
|
"scripts": {
|
|
31
|
-
"build": "node
|
|
32
|
-
"test": "node --test test/*.test.mjs
|
|
33
|
-
"sync:skill": "node scripts/sync-skill.mjs",
|
|
34
|
-
"prepack": "node scripts/verify-snapshot.mjs --quiet"
|
|
28
|
+
"build": "node --check src/index.mjs && node --check src/directory.mjs",
|
|
29
|
+
"test": "node --test test/*.test.mjs"
|
|
35
30
|
},
|
|
36
31
|
"engines": {
|
|
37
32
|
"node": ">=22"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const SAFE_SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
6
|
+
const emptyBookmarks = () => ({ schemaVersion: 1, outcomes: [] });
|
|
7
|
+
|
|
8
|
+
export const bookmarkFilePath = (environment = process.env) => join(
|
|
9
|
+
environment.POSSIBLE_HOME ? resolve(environment.POSSIBLE_HOME) : join(homedir(), ".possible"),
|
|
10
|
+
"bookmarks.json",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const validateBookmarks = (input, path) => {
|
|
14
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) throw new Error(`Bookmarks at ${path} must be a JSON object`);
|
|
15
|
+
if (input.schemaVersion !== 1 || !Array.isArray(input.outcomes)) throw new Error(`Bookmarks at ${path} must contain schemaVersion 1 and an outcomes array`);
|
|
16
|
+
const slugs = new Set();
|
|
17
|
+
const outcomes = input.outcomes.map((bookmark, index) => {
|
|
18
|
+
if (bookmark === null || typeof bookmark !== "object" || Array.isArray(bookmark)) throw new Error(`Bookmark ${index + 1} must be an object`);
|
|
19
|
+
if (Object.keys(bookmark).some((key) => !["slug", "savedAt"].includes(key))) throw new Error(`Bookmark ${index + 1} contains an unsupported field`);
|
|
20
|
+
if (!SAFE_SLUG.test(bookmark.slug)) throw new Error(`Bookmark ${index + 1}.slug must be lowercase and hyphenated`);
|
|
21
|
+
if (slugs.has(bookmark.slug)) throw new Error(`Bookmarks at ${path} contain duplicate Outcome ${bookmark.slug}`);
|
|
22
|
+
if (typeof bookmark.savedAt !== "string" || Number.isNaN(Date.parse(bookmark.savedAt))) throw new Error(`Bookmark ${index + 1}.savedAt must be an ISO date or date-time`);
|
|
23
|
+
slugs.add(bookmark.slug);
|
|
24
|
+
return { slug: bookmark.slug, savedAt: bookmark.savedAt };
|
|
25
|
+
});
|
|
26
|
+
return { schemaVersion: 1, outcomes };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const readBookmarks = async (path) => {
|
|
30
|
+
const contents = await readFile(path, "utf8").catch((error) => error?.code === "ENOENT" ? undefined : Promise.reject(error));
|
|
31
|
+
if (contents === undefined) return emptyBookmarks();
|
|
32
|
+
try {
|
|
33
|
+
return validateBookmarks(JSON.parse(contents), path);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof SyntaxError) throw new Error(`Bookmarks at ${path} contain invalid JSON and were left unchanged`);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const writeBookmarks = async (path, bookmarks) => {
|
|
41
|
+
await mkdir(dirname(path), { recursive: true });
|
|
42
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
43
|
+
await writeFile(temporaryPath, `${JSON.stringify(bookmarks, null, 2)}\n`, { flag: "wx" });
|
|
44
|
+
await rename(temporaryPath, path);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export async function runBookmarkCommand(args, options = {}) {
|
|
48
|
+
const [command, slug, ...rest] = args;
|
|
49
|
+
if (rest.length > 0) throw new Error("Bookmark commands accept only one Outcome slug");
|
|
50
|
+
const path = bookmarkFilePath(options.environment);
|
|
51
|
+
const bookmarks = await readBookmarks(path);
|
|
52
|
+
|
|
53
|
+
if (command === "list") {
|
|
54
|
+
if (slug !== undefined) throw new Error("Usage: possible bookmark list");
|
|
55
|
+
return bookmarks.outcomes.length ? bookmarks.outcomes.map(({ slug: value }) => value).join("\n") : "No bookmarked Outcomes.";
|
|
56
|
+
}
|
|
57
|
+
if ((command === "add" || command === "remove") && (!slug || !SAFE_SLUG.test(slug))) throw new Error(`Usage: possible bookmark ${command} <outcome-slug>`);
|
|
58
|
+
if (command === "add") {
|
|
59
|
+
if (bookmarks.outcomes.some(({ slug: value }) => value === slug)) return `${slug} is already bookmarked.`;
|
|
60
|
+
bookmarks.outcomes.push({ slug, savedAt: new Date().toISOString() });
|
|
61
|
+
bookmarks.outcomes.sort((left, right) => left.slug.localeCompare(right.slug));
|
|
62
|
+
await writeBookmarks(path, bookmarks);
|
|
63
|
+
return `Bookmarked ${slug}.`;
|
|
64
|
+
}
|
|
65
|
+
if (command === "remove") {
|
|
66
|
+
if (!bookmarks.outcomes.some(({ slug: value }) => value === slug)) throw new Error(`No bookmarked Outcome matches ${slug}`);
|
|
67
|
+
bookmarks.outcomes = bookmarks.outcomes.filter(({ slug: value }) => value !== slug);
|
|
68
|
+
await writeBookmarks(path, bookmarks);
|
|
69
|
+
return `Removed bookmark ${slug}.`;
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Unknown bookmark command: ${command ?? ""}`);
|
|
72
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const DEFAULT_ENDPOINT = "https://abutwsaahahtbtlopczi.supabase.co/functions/v1/outcome-directory";
|
|
2
|
+
|
|
3
|
+
const directoryEndpoint = () => process.env.POSSIBLE_DIRECTORY_ENDPOINT?.trim() || DEFAULT_ENDPOINT;
|
|
4
|
+
|
|
5
|
+
async function requestDirectory(parameters, fetchImplementation = fetch) {
|
|
6
|
+
const url = new URL(directoryEndpoint());
|
|
7
|
+
for (const [name, value] of Object.entries(parameters)) url.searchParams.set(name, value);
|
|
8
|
+
const response = await fetchImplementation(url, { headers: { accept: "application/json" } });
|
|
9
|
+
const body = await response.json().catch(() => ({}));
|
|
10
|
+
if (!response.ok) throw new Error(body.error || `Possible directory returned HTTP ${response.status}`);
|
|
11
|
+
return body;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function searchOutcomes(query, options = {}) {
|
|
15
|
+
const normalized = String(query ?? "").trim();
|
|
16
|
+
if (!normalized) throw new Error("Search requires an ordinary-language query");
|
|
17
|
+
const body = await requestDirectory({ q: normalized, limit: "5" }, options.fetchImplementation);
|
|
18
|
+
return Array.isArray(body.outcomes) ? body.outcomes : [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function fetchOutcome(id, options = {}) {
|
|
22
|
+
const normalized = String(id ?? "").trim();
|
|
23
|
+
if (!normalized) throw new Error("Fetch requires an Outcome ID from search results");
|
|
24
|
+
const body = await requestDirectory({ id: normalized }, options.fetchImplementation);
|
|
25
|
+
if (!body.outcome || typeof body.outcome.prompt !== "string") throw new Error("Possible returned an invalid Outcome");
|
|
26
|
+
return body.outcome;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatSearchResults(outcomes) {
|
|
30
|
+
if (!outcomes.length) return "No matching Outcomes found.\n";
|
|
31
|
+
return `${outcomes.map((outcome, index) => [
|
|
32
|
+
`${index + 1}. ${outcome.title}`,
|
|
33
|
+
` ${outcome.summary}`,
|
|
34
|
+
` ID: ${outcome.id}`,
|
|
35
|
+
` Source: ${outcome.source_locator}`,
|
|
36
|
+
].join("\n")).join("\n\n")}\n`;
|
|
37
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -1,35 +1,105 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import {
|
|
4
|
+
import { runBookmarkCommand } from "./bookmarks.mjs";
|
|
5
|
+
import { fetchOutcome, formatSearchResults, searchOutcomes } from "./directory.mjs";
|
|
6
|
+
import { addOutcomeSource, createOutcome, publishOutcomeSource, useOutcome, validateOutcomes } from "./outcome-commands.mjs";
|
|
5
7
|
|
|
6
8
|
const HELP = `Possible CLI
|
|
7
9
|
|
|
8
10
|
Usage:
|
|
9
|
-
possible
|
|
11
|
+
possible create <slug>
|
|
12
|
+
possible validate [directory]
|
|
13
|
+
possible publish [owner/repository | https://publisher.example]
|
|
14
|
+
possible search <ordinary-language query>
|
|
15
|
+
possible fetch <outcome-id>
|
|
16
|
+
possible add <owner/repository | https://publisher.example>
|
|
17
|
+
possible use <source>@<slug>
|
|
18
|
+
possible bookmark <command>
|
|
10
19
|
|
|
11
20
|
Commands:
|
|
12
|
-
|
|
21
|
+
create Create outcome.json, outcome.md, prompt.md, and media/ for one Outcome
|
|
22
|
+
validate Validate every Outcome folder below a directory
|
|
23
|
+
publish Validate and submit one public publisher source; no account required
|
|
24
|
+
search Find relevant Outcomes in the live public directory
|
|
25
|
+
fetch Print one directory Outcome's exact prompt
|
|
26
|
+
add Discover a public source and save it to .possible/sources.json
|
|
27
|
+
use Print one exact execution prompt to standard output
|
|
28
|
+
bookmark add | list | remove locally saved Outcome slugs
|
|
13
29
|
`;
|
|
14
30
|
|
|
15
31
|
const args = process.argv.slice(2);
|
|
16
|
-
|
|
17
32
|
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
18
33
|
process.stdout.write(HELP);
|
|
19
34
|
process.exitCode = args.length === 0 ? 1 : 0;
|
|
20
|
-
} else if (args
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
} else if (args[0] === "bookmark") {
|
|
36
|
+
try {
|
|
37
|
+
const result = await runBookmarkCommand(args.slice(1));
|
|
38
|
+
if (result) process.stdout.write(`${result}\n`);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
}
|
|
43
|
+
} else if (args[0] === "search" && args.length >= 2) {
|
|
44
|
+
try {
|
|
45
|
+
const outcomes = await searchOutcomes(args.slice(1).join(" "));
|
|
46
|
+
process.stdout.write(formatSearchResults(outcomes));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
}
|
|
51
|
+
} else if (args[0] === "fetch" && args.length === 2) {
|
|
52
|
+
try {
|
|
53
|
+
const outcome = await fetchOutcome(args[1]);
|
|
54
|
+
process.stdout.write(`${outcome.prompt}\n`);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
}
|
|
59
|
+
} else if (args[0] === "create" && args.length === 2) {
|
|
60
|
+
try {
|
|
61
|
+
const folder = await createOutcome(args[1]);
|
|
62
|
+
process.stdout.write(`Created ${folder}\n`);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
|
67
|
+
} else if (args[0] === "validate" && args.length <= 2) {
|
|
24
68
|
try {
|
|
25
|
-
const result = await
|
|
26
|
-
process.stdout.write(
|
|
27
|
-
`${result.changed ? "Possible installed" : "Possible is already installed"} at ${result.installPath}\n\n` +
|
|
28
|
-
"Open Codex in this project and type:\n\n" +
|
|
29
|
-
" $possible\n",
|
|
30
|
-
);
|
|
69
|
+
const result = await validateOutcomes(args[1] ?? process.cwd());
|
|
70
|
+
process.stdout.write(`Validated ${result.count} Outcome${result.count === 1 ? "" : "s"}.\n`);
|
|
31
71
|
} catch (error) {
|
|
32
72
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
33
73
|
process.exitCode = 1;
|
|
34
74
|
}
|
|
75
|
+
} else if (args[0] === "add" && args.length === 2) {
|
|
76
|
+
try {
|
|
77
|
+
const result = await addOutcomeSource(args[1]);
|
|
78
|
+
process.stdout.write(`Added ${result.discovery.locator}: ${result.discovery.outcomes.length} Outcome${result.discovery.outcomes.length === 1 ? "" : "s"}.\nSaved ${result.sourcesPath}\n`);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
83
|
+
} else if (args[0] === "use" && args.length === 2) {
|
|
84
|
+
try {
|
|
85
|
+
const result = await useOutcome(args[1]);
|
|
86
|
+
process.stdout.write(`${result.outcome.prompt}\n`);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
}
|
|
91
|
+
} else if (args[0] === "publish" && args.length <= 2) {
|
|
92
|
+
try {
|
|
93
|
+
const result = await publishOutcomeSource(args[1]);
|
|
94
|
+
const publicUrl = result.result.url ?? result.result.href;
|
|
95
|
+
const outcomes = Array.isArray(result.result.outcomes) ? result.result.outcomes : [];
|
|
96
|
+
const locator = result.result.source?.locator ?? result.source;
|
|
97
|
+
process.stdout.write(`Published ${outcomes.length} Outcome${outcomes.length === 1 ? "" : "s"} from ${locator}.${publicUrl ? `\n${publicUrl}` : ""}\n`);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
process.stderr.write(`Unknown command: ${args.join(" ")}\n\n${HELP}`);
|
|
104
|
+
process.exitCode = 1;
|
|
35
105
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { discoverLocalOutcomes } from "./outcome-format.mjs";
|
|
6
|
+
import { discoverOutcomeSource } from "./sources.mjs";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const SAFE_SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
10
|
+
const DEFAULT_PUBLISH_ENDPOINT = "https://abutwsaahahtbtlopczi.supabase.co/functions/v1/register-outcome-source";
|
|
11
|
+
|
|
12
|
+
export async function createOutcome(slug, { directory = process.cwd() } = {}) {
|
|
13
|
+
if (!SAFE_SLUG.test(slug ?? "")) throw new Error("Outcome slug must be lowercase and hyphenated");
|
|
14
|
+
const root = resolve(directory);
|
|
15
|
+
const indexPath = join(root, "outcomes.json");
|
|
16
|
+
let publisherIndex = {
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
publisher: { name: "Replace with publisher name", url: "https://example.com" },
|
|
19
|
+
outcomes: [],
|
|
20
|
+
};
|
|
21
|
+
try {
|
|
22
|
+
publisherIndex = JSON.parse(await readFile(indexPath, "utf8"));
|
|
23
|
+
if (publisherIndex.schemaVersion !== 1 || !Array.isArray(publisherIndex.outcomes)) throw new Error("outcomes.json must be a publisher index with schemaVersion 1");
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.code !== "ENOENT") throw error;
|
|
26
|
+
}
|
|
27
|
+
const folder = join(root, "outcomes", slug);
|
|
28
|
+
await mkdir(join(folder, "media"), { recursive: true });
|
|
29
|
+
const manifestPath = join(folder, "outcome.json");
|
|
30
|
+
const manifest = {
|
|
31
|
+
schemaVersion: 3,
|
|
32
|
+
slug,
|
|
33
|
+
files: { about: "outcome.md", prompt: "prompt.md" },
|
|
34
|
+
authoredAt: null,
|
|
35
|
+
author: { name: "Replace with publisher name", url: "https://example.com" },
|
|
36
|
+
models: [{ provider: "Replace with provider", model: "Replace with model", role: "execution" }],
|
|
37
|
+
requirements: [],
|
|
38
|
+
};
|
|
39
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" });
|
|
40
|
+
await writeFile(join(folder, "outcome.md"), `# Replace with Outcome name\n\nDescribe the concrete result in one clear opening paragraph.\n`, { flag: "wx" });
|
|
41
|
+
await writeFile(join(folder, "prompt.md"), "Replace this line with the exact reusable execution prompt.\n", { flag: "wx" });
|
|
42
|
+
const entry = { slug, url: `./outcomes/${slug}/outcome.json` };
|
|
43
|
+
publisherIndex.outcomes = [...publisherIndex.outcomes.filter((outcome) => outcome?.slug !== slug), entry]
|
|
44
|
+
.sort((left, right) => String(left.slug).localeCompare(String(right.slug)));
|
|
45
|
+
await writeFile(indexPath, `${JSON.stringify(publisherIndex, null, 2)}\n`);
|
|
46
|
+
return folder;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function validateOutcomes(directory = process.cwd()) {
|
|
50
|
+
const root = resolve(directory);
|
|
51
|
+
const outcomes = await discoverLocalOutcomes(root);
|
|
52
|
+
const indexPath = join(root, "outcomes.json");
|
|
53
|
+
let publisherIndex;
|
|
54
|
+
try { publisherIndex = JSON.parse(await readFile(indexPath, "utf8")); }
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error?.code === "ENOENT") throw new Error("outcomes.json is required at the publisher repository root");
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
if (publisherIndex?.schemaVersion !== 1 || !Array.isArray(publisherIndex.outcomes) || publisherIndex.outcomes.length === 0) throw new Error("outcomes.json must be a non-empty publisher index with schemaVersion 1");
|
|
60
|
+
if (!publisherIndex.publisher || typeof publisherIndex.publisher.name !== "string" || !publisherIndex.publisher.name.trim()) throw new Error("outcomes.json requires publisher.name");
|
|
61
|
+
try {
|
|
62
|
+
if (new URL(publisherIndex.publisher.url).protocol !== "https:") throw new Error();
|
|
63
|
+
} catch { throw new Error("outcomes.json requires an HTTPS publisher.url"); }
|
|
64
|
+
const indexed = new Map();
|
|
65
|
+
for (const [position, entry] of publisherIndex.outcomes.entries()) {
|
|
66
|
+
if (!entry || typeof entry.slug !== "string" || !SAFE_SLUG.test(entry.slug)) throw new Error(`outcomes.json outcomes[${position}].slug is invalid`);
|
|
67
|
+
const expectedUrl = `./outcomes/${entry.slug}/outcome.json`;
|
|
68
|
+
if (entry.url !== expectedUrl) throw new Error(`outcomes.json outcomes[${position}].url must be ${expectedUrl}`);
|
|
69
|
+
if (indexed.has(entry.slug)) throw new Error(`outcomes.json contains duplicate slug ${entry.slug}`);
|
|
70
|
+
indexed.set(entry.slug, entry);
|
|
71
|
+
}
|
|
72
|
+
const discovered = new Set(outcomes.map(({ slug }) => slug));
|
|
73
|
+
for (const slug of indexed.keys()) if (!discovered.has(slug)) throw new Error(`outcomes.json references missing Outcome ${slug}`);
|
|
74
|
+
for (const slug of discovered) if (!indexed.has(slug)) throw new Error(`outcomes.json does not list Outcome ${slug}`);
|
|
75
|
+
return { count: outcomes.length, outcomes };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function gitOutput(directory, args) {
|
|
79
|
+
const result = await execFileAsync("git", args, { cwd: directory, encoding: "utf8" });
|
|
80
|
+
return result.stdout.trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function inferGitHubSource(directory = process.cwd()) {
|
|
84
|
+
const root = await gitOutput(directory, ["rev-parse", "--show-toplevel"]);
|
|
85
|
+
const dirty = await gitOutput(root, ["status", "--porcelain"]);
|
|
86
|
+
if (dirty) throw new Error("Commit the Outcome files before publishing so Possible can snapshot an exact public revision");
|
|
87
|
+
const remote = await gitOutput(root, ["remote", "get-url", "origin"]);
|
|
88
|
+
const match = remote.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
89
|
+
if (!match) throw new Error("The repository origin must be a public GitHub repository");
|
|
90
|
+
return `${match[1]}/${match[2]}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function addOutcomeSource(source, { directory = process.cwd(), fetchOptions } = {}) {
|
|
94
|
+
const discovery = await discoverOutcomeSource(source, fetchOptions);
|
|
95
|
+
const possibleDirectory = join(directory, ".possible");
|
|
96
|
+
const sourcesPath = join(possibleDirectory, "sources.json");
|
|
97
|
+
await mkdir(possibleDirectory, { recursive: true });
|
|
98
|
+
let current = { schemaVersion: 1, sources: [] };
|
|
99
|
+
try { current = JSON.parse(await readFile(sourcesPath, "utf8")); } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
100
|
+
const nextSource = { type: discovery.type, locator: discovery.locator, installUrl: discovery.installUrl, revision: discovery.revision };
|
|
101
|
+
const sources = [...(Array.isArray(current.sources) ? current.sources : []).filter((entry) => !(entry.type === nextSource.type && entry.locator === nextSource.locator)), nextSource]
|
|
102
|
+
.sort((left, right) => left.locator.localeCompare(right.locator));
|
|
103
|
+
await writeFile(sourcesPath, `${JSON.stringify({ schemaVersion: 1, sources }, null, 2)}\n`);
|
|
104
|
+
return { discovery, sourcesPath };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function splitUseReference(reference) {
|
|
108
|
+
const separator = reference.lastIndexOf("@");
|
|
109
|
+
if (separator <= 0 || separator === reference.length - 1) throw new Error("Use an Outcome as <source>@<slug>");
|
|
110
|
+
return { source: reference.slice(0, separator), slug: reference.slice(separator + 1) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function useOutcome(reference, { fetchOptions } = {}) {
|
|
114
|
+
const { source, slug } = splitUseReference(reference);
|
|
115
|
+
const discovery = await discoverOutcomeSource(source, fetchOptions);
|
|
116
|
+
const outcome = discovery.outcomes.find((entry) => entry.slug === slug);
|
|
117
|
+
if (!outcome) throw new Error(`${source} does not publish an Outcome named ${slug}`);
|
|
118
|
+
return { discovery, outcome };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function publishOutcomeSource(source, { directory = process.cwd(), endpoint = process.env.POSSIBLE_PUBLISH_URL ?? DEFAULT_PUBLISH_ENDPOINT } = {}) {
|
|
122
|
+
const resolvedSource = source || await inferGitHubSource(directory);
|
|
123
|
+
if (!source) await validateOutcomes(directory);
|
|
124
|
+
const response = await fetch(endpoint, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
127
|
+
body: JSON.stringify({ source: resolvedSource }),
|
|
128
|
+
});
|
|
129
|
+
const body = await response.text();
|
|
130
|
+
if (!response.ok) throw new Error(`Possible publishing returned HTTP ${response.status}${body ? `: ${body}` : ""}`);
|
|
131
|
+
let result = {};
|
|
132
|
+
try { result = body ? JSON.parse(body) : {}; } catch { result = { message: body }; }
|
|
133
|
+
return { source: resolvedSource, result };
|
|
134
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const SAFE_SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
5
|
+
const EXACT_REVISION = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
6
|
+
const PRODUCT_ID = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
|
|
7
|
+
const GITHUB_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
8
|
+
const SAFE_REPOSITORY_PATH = /^(?:\.|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)$/;
|
|
9
|
+
const MODEL_ROLES = new Set(["authorship", "execution", "review"]);
|
|
10
|
+
const FILE_TYPES = new Set(["image", "video", "audio", "cad", "document", "data", "source", "archive", "other"]);
|
|
11
|
+
const MANIFEST_KEYS = new Set(["schemaVersion", "slug", "files", "authoredAt", "author", "models", "requirements", "products", "skills", "inputs", "artifacts", "preview"]);
|
|
12
|
+
|
|
13
|
+
const asObject = (value, context) => {
|
|
14
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${context} must be a JSON object`);
|
|
15
|
+
return value;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const string = (value, context) => {
|
|
19
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${context} must be a non-empty string`);
|
|
20
|
+
return value;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const exactKeys = (value, allowed, context) => {
|
|
24
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${context}.${key} is unsupported`);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const httpsUrl = (value, context) => {
|
|
28
|
+
const candidate = string(value, context);
|
|
29
|
+
let parsed;
|
|
30
|
+
try { parsed = new URL(candidate); } catch { throw new Error(`${context} must be an HTTPS URL`); }
|
|
31
|
+
if (parsed.protocol !== "https:") throw new Error(`${context} must be an HTTPS URL`);
|
|
32
|
+
return candidate;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const safeRelativePath = (value, context) => {
|
|
36
|
+
const candidate = string(value, context);
|
|
37
|
+
if (candidate.includes("\\") || candidate.startsWith("/") || candidate.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
38
|
+
throw new Error(`${context} must be a safe repository-relative path`);
|
|
39
|
+
}
|
|
40
|
+
return candidate;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const validateFiles = (value, context) => {
|
|
44
|
+
if (value === undefined) return;
|
|
45
|
+
if (!Array.isArray(value) || value.length === 0) throw new Error(`${context} must be omitted or a non-empty array`);
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
value.forEach((entry, index) => {
|
|
48
|
+
const file = asObject(entry, `${context}[${index}]`);
|
|
49
|
+
exactKeys(file, new Set(["type", "src", "label", "format"]), `${context}[${index}]`);
|
|
50
|
+
if (!FILE_TYPES.has(file.type)) throw new Error(`${context}[${index}].type is unsupported`);
|
|
51
|
+
const source = file.src?.startsWith("https://") ? httpsUrl(file.src, `${context}[${index}].src`) : safeRelativePath(file.src, `${context}[${index}].src`);
|
|
52
|
+
string(file.label, `${context}[${index}].label`);
|
|
53
|
+
if (file.format !== undefined) string(file.format, `${context}[${index}].format`);
|
|
54
|
+
if (seen.has(source)) throw new Error(`${context} contains duplicate source ${source}`);
|
|
55
|
+
seen.add(source);
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function validateOutcomeManifest(value, context = "outcome.json") {
|
|
60
|
+
const manifest = asObject(value, context);
|
|
61
|
+
exactKeys(manifest, MANIFEST_KEYS, context);
|
|
62
|
+
if (manifest.schemaVersion !== 3) throw new Error(`${context}.schemaVersion must be 3`);
|
|
63
|
+
if (!SAFE_SLUG.test(string(manifest.slug, `${context}.slug`))) throw new Error(`${context}.slug must be lowercase and hyphenated`);
|
|
64
|
+
|
|
65
|
+
const files = asObject(manifest.files, `${context}.files`);
|
|
66
|
+
exactKeys(files, new Set(["about", "prompt"]), `${context}.files`);
|
|
67
|
+
if (files.about !== "outcome.md" || files.prompt !== "prompt.md") throw new Error(`${context}.files must reference outcome.md and prompt.md`);
|
|
68
|
+
|
|
69
|
+
if (manifest.authoredAt !== null) {
|
|
70
|
+
const authoredAt = string(manifest.authoredAt, `${context}.authoredAt`);
|
|
71
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})$/.test(authoredAt) || Number.isNaN(Date.parse(authoredAt))) {
|
|
72
|
+
throw new Error(`${context}.authoredAt must be null or an ISO 8601 timestamp with a timezone`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const author = asObject(manifest.author, `${context}.author`);
|
|
77
|
+
exactKeys(author, new Set(["name", "url"]), `${context}.author`);
|
|
78
|
+
string(author.name, `${context}.author.name`);
|
|
79
|
+
httpsUrl(author.url, `${context}.author.url`);
|
|
80
|
+
|
|
81
|
+
if (!Array.isArray(manifest.models) || manifest.models.length === 0) throw new Error(`${context}.models must be a non-empty array`);
|
|
82
|
+
manifest.models.forEach((entry, index) => {
|
|
83
|
+
const model = asObject(entry, `${context}.models[${index}]`);
|
|
84
|
+
exactKeys(model, new Set(["provider", "model", "agent", "role"]), `${context}.models[${index}]`);
|
|
85
|
+
string(model.provider, `${context}.models[${index}].provider`);
|
|
86
|
+
string(model.model, `${context}.models[${index}].model`);
|
|
87
|
+
if (model.agent !== undefined) string(model.agent, `${context}.models[${index}].agent`);
|
|
88
|
+
if (!MODEL_ROLES.has(model.role)) throw new Error(`${context}.models[${index}].role is unsupported`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!Array.isArray(manifest.requirements)) throw new Error(`${context}.requirements must be an array`);
|
|
92
|
+
const requirements = manifest.requirements.map((entry, index) => string(entry, `${context}.requirements[${index}]`));
|
|
93
|
+
if (new Set(requirements).size !== requirements.length) throw new Error(`${context}.requirements contains duplicates`);
|
|
94
|
+
|
|
95
|
+
if (manifest.products !== undefined) {
|
|
96
|
+
if (!Array.isArray(manifest.products) || manifest.products.length === 0) throw new Error(`${context}.products must be omitted or a non-empty array`);
|
|
97
|
+
for (const [index, product] of manifest.products.entries()) if (!PRODUCT_ID.test(string(product, `${context}.products[${index}]`))) throw new Error(`${context}.products[${index}] is invalid`);
|
|
98
|
+
if (new Set(manifest.products).size !== manifest.products.length) throw new Error(`${context}.products contains duplicates`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (manifest.skills !== undefined) {
|
|
102
|
+
if (!Array.isArray(manifest.skills) || manifest.skills.length === 0) throw new Error(`${context}.skills must be omitted or a non-empty array`);
|
|
103
|
+
for (const [index, entry] of manifest.skills.entries()) {
|
|
104
|
+
const skill = asObject(entry, `${context}.skills[${index}]`);
|
|
105
|
+
exactKeys(skill, new Set(["repository", "lastReviewedCommit", "directory"]), `${context}.skills[${index}]`);
|
|
106
|
+
if (!GITHUB_REPOSITORY.test(string(skill.repository, `${context}.skills[${index}].repository`))) throw new Error(`${context}.skills[${index}].repository is invalid`);
|
|
107
|
+
if (!EXACT_REVISION.test(string(skill.lastReviewedCommit, `${context}.skills[${index}].lastReviewedCommit`))) throw new Error(`${context}.skills[${index}].lastReviewedCommit must be an exact commit`);
|
|
108
|
+
const directory = string(skill.directory, `${context}.skills[${index}].directory`);
|
|
109
|
+
if (!SAFE_REPOSITORY_PATH.test(directory) || (directory !== "." && directory.split("/").some((part) => part === "." || part === ".."))) throw new Error(`${context}.skills[${index}].directory is invalid`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
validateFiles(manifest.inputs, `${context}.inputs`);
|
|
114
|
+
validateFiles(manifest.artifacts, `${context}.artifacts`);
|
|
115
|
+
if (manifest.preview !== undefined) asObject(manifest.preview, `${context}.preview`);
|
|
116
|
+
return manifest;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const plainInlineMarkdown = (value) => value
|
|
120
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
121
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
122
|
+
.replace(/[*_~`]/g, "")
|
|
123
|
+
.replace(/\s+/g, " ")
|
|
124
|
+
.trim();
|
|
125
|
+
|
|
126
|
+
export function parseOutcomeMarkdown(value, context = "outcome.md") {
|
|
127
|
+
const markdown = string(value, context).trim();
|
|
128
|
+
const lines = markdown.split(/\r?\n/);
|
|
129
|
+
const titleLine = lines[0] ?? "";
|
|
130
|
+
if (!titleLine.startsWith("# ") || titleLine.slice(2).trim().length === 0) throw new Error(`${context} must begin with one # title`);
|
|
131
|
+
if (lines.slice(1).some((line) => line.startsWith("# "))) throw new Error(`${context} must contain exactly one # title`);
|
|
132
|
+
let index = 1;
|
|
133
|
+
while (index < lines.length && lines[index]?.trim() === "") index += 1;
|
|
134
|
+
const summaryLines = [];
|
|
135
|
+
while (index < lines.length && lines[index]?.trim() !== "" && !lines[index]?.startsWith("## ")) {
|
|
136
|
+
summaryLines.push(lines[index]);
|
|
137
|
+
index += 1;
|
|
138
|
+
}
|
|
139
|
+
const summaryMarkdown = summaryLines.join("\n").trim();
|
|
140
|
+
if (!summaryMarkdown) throw new Error(`${context} must contain an opening summary after its title`);
|
|
141
|
+
|
|
142
|
+
const originalHeading = lines.findIndex((line) => line.trim().toLowerCase() === "## original request");
|
|
143
|
+
let originalPrompt;
|
|
144
|
+
if (originalHeading >= 0) {
|
|
145
|
+
const quoted = [];
|
|
146
|
+
for (const line of lines.slice(originalHeading + 1)) {
|
|
147
|
+
if (line.startsWith("## ")) break;
|
|
148
|
+
if (line.startsWith("> ")) quoted.push(line.slice(2));
|
|
149
|
+
else if (line === ">") quoted.push("");
|
|
150
|
+
}
|
|
151
|
+
const joined = quoted.join("\n").trim();
|
|
152
|
+
if (joined) originalPrompt = joined;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
title: plainInlineMarkdown(titleLine.slice(2)),
|
|
156
|
+
summary: plainInlineMarkdown(summaryMarkdown),
|
|
157
|
+
summaryMarkdown,
|
|
158
|
+
markdown,
|
|
159
|
+
...(originalPrompt ? { originalPrompt } : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function readOutcomeFolder(folder) {
|
|
164
|
+
const [manifestText, aboutText, promptText] = await Promise.all([
|
|
165
|
+
readFile(join(folder, "outcome.json"), "utf8"),
|
|
166
|
+
readFile(join(folder, "outcome.md"), "utf8"),
|
|
167
|
+
readFile(join(folder, "prompt.md"), "utf8"),
|
|
168
|
+
]);
|
|
169
|
+
const manifest = validateOutcomeManifest(JSON.parse(manifestText), `${relative(process.cwd(), join(folder, "outcome.json")) || "outcome.json"}`);
|
|
170
|
+
if (basename(folder) !== manifest.slug) throw new Error(`${manifest.slug}: folder name must match outcome.json slug`);
|
|
171
|
+
const about = parseOutcomeMarkdown(aboutText, `${manifest.slug}/outcome.md`);
|
|
172
|
+
const executionPrompt = string(promptText, `${manifest.slug}/prompt.md`).trim();
|
|
173
|
+
return { slug: manifest.slug, folder, manifest, about, executionPrompt };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function discoverLocalOutcomes(repositoryRoot) {
|
|
177
|
+
const found = [];
|
|
178
|
+
async function walk(directory) {
|
|
179
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
180
|
+
if (entries.some((entry) => entry.isFile() && entry.name === "outcome.json")) {
|
|
181
|
+
found.push(await readOutcomeFolder(directory));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === ".git" || entry.name === "node_modules") continue;
|
|
186
|
+
await walk(join(directory, entry.name));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
await walk(resolve(repositoryRoot));
|
|
190
|
+
if (found.length === 0) throw new Error(`No Outcome folders found under ${repositoryRoot}`);
|
|
191
|
+
const identities = new Set();
|
|
192
|
+
for (const outcome of found) {
|
|
193
|
+
if (identities.has(outcome.slug)) throw new Error(`Duplicate Outcome slug: ${outcome.slug}`);
|
|
194
|
+
identities.add(outcome.slug);
|
|
195
|
+
}
|
|
196
|
+
return found.sort((left, right) => left.about.title.localeCompare(right.about.title));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function relativeOutcomePath(repositoryRoot, folder) {
|
|
200
|
+
return relative(resolve(repositoryRoot), resolve(folder)).split("\\").join("/");
|
|
201
|
+
}
|