mirror of
https://github.com/EsotericSoftware/spine-runtimes.git
synced 2026-03-26 22:49:01 +08:00
[runtimes] Add forum skill, prompt-url extension, update AGENTS.md with eclipse file rule
This commit is contained in:
parent
feceba3cb7
commit
be5c905b7d
@ -1,12 +1,10 @@
|
|||||||
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
|
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
import { Container, Text } from "@mariozechner/pi-tui";
|
import { Container, Text } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
|
||||||
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
|
||||||
|
|
||||||
type PromptMatch = {
|
type PromptMatch = {
|
||||||
kind: "pr" | "issue";
|
kind: "pr" | "issue" | "forum";
|
||||||
url: string;
|
url: string;
|
||||||
|
forumId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GhMetadata = {
|
type GhMetadata = {
|
||||||
@ -17,18 +15,37 @@ type GhMetadata = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const URL_PATTERNS: { kind: PromptMatch["kind"]; pattern: RegExp }[] = [
|
||||||
|
{ kind: "pr", pattern: /https?:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+[^\s]*/gi },
|
||||||
|
{ kind: "issue", pattern: /https?:\/\/github\.com\/[^\s/]+\/[^\s/]+\/issues\/\d+[^\s]*/gi },
|
||||||
|
{ kind: "forum", pattern: /https?:\/\/(?:www\.)?esotericsoftware\.com\/forum\/d\/(\d+)[^\s]*/gi },
|
||||||
|
];
|
||||||
|
|
||||||
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
||||||
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
let last: PromptMatch | undefined;
|
||||||
if (prMatch?.[1]) {
|
|
||||||
return { kind: "pr", url: prMatch[1].trim() };
|
for (const { kind, pattern } of URL_PATTERNS) {
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = pattern.exec(prompt)) !== null) {
|
||||||
|
const url = m[0].trim();
|
||||||
|
const forumId = kind === "forum" ? m[1] : undefined;
|
||||||
|
last = { kind, url, forumId };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
return last;
|
||||||
if (issueMatch?.[1]) {
|
}
|
||||||
return { kind: "issue", url: issueMatch[1].trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
async function fetchForumMetadata(forumId: string): Promise<{ title?: string } | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://esotericsoftware.com/forum/api/discussions/${forumId}`);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
const data = (await res.json()) as { data?: { attributes?: { title?: string } } };
|
||||||
|
return { title: data?.data?.attributes?.title };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchGhMetadata(
|
async function fetchGhMetadata(
|
||||||
@ -76,8 +93,20 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchMetadata = async (
|
||||||
|
api: ExtensionAPI,
|
||||||
|
match: PromptMatch,
|
||||||
|
): Promise<{ title?: string; authorText?: string }> => {
|
||||||
|
if (match.kind === "forum" && match.forumId) {
|
||||||
|
const meta = await fetchForumMetadata(match.forumId);
|
||||||
|
return { title: meta?.title?.trim() };
|
||||||
|
}
|
||||||
|
const meta = await fetchGhMetadata(api, match.kind as "pr" | "issue", match.url);
|
||||||
|
return { title: meta?.title?.trim(), authorText: formatAuthor(meta?.author) };
|
||||||
|
};
|
||||||
|
|
||||||
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
|
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
|
||||||
const label = match.kind === "pr" ? "PR" : "Issue";
|
const label = match.kind === "pr" ? "PR" : match.kind === "issue" ? "Issue" : "Forum";
|
||||||
const trimmedTitle = title?.trim();
|
const trimmedTitle = title?.trim();
|
||||||
const fallbackName = `${label}: ${match.url}`;
|
const fallbackName = `${label}: ${match.url}`;
|
||||||
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
||||||
@ -100,9 +129,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
setWidget(ctx, match);
|
setWidget(ctx, match);
|
||||||
applySessionName(ctx, match);
|
applySessionName(ctx, match);
|
||||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
void fetchMetadata(pi, match).then(({ title, authorText }) => {
|
||||||
const title = meta?.title?.trim();
|
|
||||||
const authorText = formatAuthor(meta?.author);
|
|
||||||
setWidget(ctx, match, title, authorText);
|
setWidget(ctx, match, title, authorText);
|
||||||
applySessionName(ctx, match, title);
|
applySessionName(ctx, match, title);
|
||||||
});
|
});
|
||||||
@ -144,9 +171,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
setWidget(ctx, match);
|
setWidget(ctx, match);
|
||||||
applySessionName(ctx, match);
|
applySessionName(ctx, match);
|
||||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
void fetchMetadata(pi, match).then(({ title, authorText }) => {
|
||||||
const title = meta?.title?.trim();
|
|
||||||
const authorText = formatAuthor(meta?.author);
|
|
||||||
setWidget(ctx, match, title, authorText);
|
setWidget(ctx, match, title, authorText);
|
||||||
applySessionName(ctx, match, title);
|
applySessionName(ctx, match, title);
|
||||||
});
|
});
|
||||||
|
|||||||
34
.pi/skills/forum/SKILL.md
Normal file
34
.pi/skills/forum/SKILL.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: forum
|
||||||
|
description: Fetch Spine forum discussion threads. Use when the user shares a forum URL (esotericsoftware.com/forum/d/...) or asks about a forum thread.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spine Forum
|
||||||
|
|
||||||
|
Fetch discussion threads from the Spine forum (Flarum) via its public REST API. No authentication needed.
|
||||||
|
|
||||||
|
## Fetch a thread
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{baseDir}/fetch.js <url_or_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts a full forum URL or just the discussion ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{baseDir}/fetch.js https://esotericsoftware.com/forum/d/29888-spine-ue-world-movement-not-affecting-physics/5
|
||||||
|
{baseDir}/fetch.js 29888
|
||||||
|
```
|
||||||
|
|
||||||
|
Output includes all posts as plain text with code blocks preserved. Image and video URLs are listed after each post as `[IMAGE]` / `[VIDEO]` lines. Use the `read` tool on image URLs to view them.
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
- `--html` - Print raw HTML instead of converting to text
|
||||||
|
- `--json` - Print the raw JSON API response
|
||||||
|
|
||||||
|
## Extracting the discussion ID from a URL
|
||||||
|
|
||||||
|
Forum URLs: `https://esotericsoftware.com/forum/d/{ID}-{slug}/{postIndex}`
|
||||||
|
|
||||||
|
The ID is the number after `/d/`. The slug and post index are irrelevant for API access.
|
||||||
147
.pi/skills/forum/fetch.js
Executable file
147
.pi/skills/forum/fetch.js
Executable file
@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Spine forum discussion threads via the Flarum REST API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* fetch.js <url_or_id> Print all posts as plain text with media URLs
|
||||||
|
* fetch.js <url_or_id> --html Print raw HTML instead of stripping tags
|
||||||
|
* fetch.js <url_or_id> --json Print raw JSON API response
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* fetch.js https://esotericsoftware.com/forum/d/29888-spine-ue-world-movement-not-affecting-physics/5
|
||||||
|
* fetch.js 29888
|
||||||
|
* fetch.js 29888 --html
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require("https");
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
const BASE = "https://esotericsoftware.com/forum/api";
|
||||||
|
|
||||||
|
function extractId(arg) {
|
||||||
|
const m = arg.match(/\/d\/(\d+)/);
|
||||||
|
if (m) return m[1];
|
||||||
|
if (/^\d+$/.test(arg)) return arg;
|
||||||
|
console.error(`Error: cannot extract discussion ID from: ${arg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetch(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const lib = url.startsWith("https") ? https : http;
|
||||||
|
lib.get(url, { headers: { Accept: "application/json" } }, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
return fetch(res.headers.location).then(resolve, reject);
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
res.on("data", (c) => chunks.push(c));
|
||||||
|
res.on("end", () => {
|
||||||
|
const body = Buffer.concat(chunks).toString();
|
||||||
|
try { resolve(JSON.parse(body)); }
|
||||||
|
catch (e) { reject(new Error(`JSON parse error: ${e.message}\n${body.slice(0, 200)}`)); }
|
||||||
|
});
|
||||||
|
res.on("error", reject);
|
||||||
|
}).on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(s) {
|
||||||
|
return s
|
||||||
|
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToText(h) {
|
||||||
|
// Code blocks
|
||||||
|
h = h.replace(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => {
|
||||||
|
return "\n```\n" + decodeEntities(code.replace(/<[^>]+>/g, "")) + "\n```\n";
|
||||||
|
});
|
||||||
|
// Inline code
|
||||||
|
h = h.replace(/<code>(.*?)<\/code>/gi, (_, code) => "`" + decodeEntities(code) + "`");
|
||||||
|
// Strip script tags entirely
|
||||||
|
h = h.replace(/<script[\s\S]*?<\/script>/gi, "");
|
||||||
|
// Line breaks
|
||||||
|
h = h.replace(/<br\s*\/?>/gi, "\n");
|
||||||
|
// Block boundaries
|
||||||
|
h = h.replace(/<\/?(p|div|li|ol|ul|h[1-6]|blockquote)[^>]*>/gi, "\n");
|
||||||
|
// Strip remaining tags
|
||||||
|
h = h.replace(/<[^>]+>/g, "");
|
||||||
|
// Decode entities
|
||||||
|
h = decodeEntities(h);
|
||||||
|
// Collapse blank lines
|
||||||
|
h = h.replace(/\n{3,}/g, "\n\n");
|
||||||
|
return h.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMedia(h) {
|
||||||
|
const imgs = [...h.matchAll(/<img[^>]+src="([^"]+)"/g)].map((m) => m[1]);
|
||||||
|
const vids = [...h.matchAll(/<video[^>]+src="([^"]+)"/g)].map((m) => m[1]);
|
||||||
|
return { imgs, vids };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
|
||||||
|
console.log(`Usage: fetch.js <url_or_id> [--html|--json]`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const discId = extractId(args[0]);
|
||||||
|
const mode = args.includes("--html") ? "html" : args.includes("--json") ? "json" : "text";
|
||||||
|
|
||||||
|
const url = `${BASE}/discussions/${discId}?include=posts,posts.user`;
|
||||||
|
const data = await fetch(url);
|
||||||
|
|
||||||
|
if (mode === "json") {
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const included = data.included || [];
|
||||||
|
const users = {};
|
||||||
|
for (const item of included) {
|
||||||
|
if (item.type === "users") {
|
||||||
|
users[item.id] = item.attributes.displayName || item.attributes.username || "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = included
|
||||||
|
.filter((i) => i.type === "posts" && i.attributes.contentType === "comment")
|
||||||
|
.sort((a, b) => a.attributes.createdAt.localeCompare(b.attributes.createdAt));
|
||||||
|
|
||||||
|
const title = data.data.attributes.title;
|
||||||
|
console.log(`# ${title}\n`);
|
||||||
|
|
||||||
|
for (const p of posts) {
|
||||||
|
const uid = p.relationships.user.data.id;
|
||||||
|
const author = users[uid] || "?";
|
||||||
|
const created = p.attributes.createdAt;
|
||||||
|
const rawHtml = p.attributes.contentHtml || "";
|
||||||
|
|
||||||
|
console.log(`## ${author} (${created})\n`);
|
||||||
|
|
||||||
|
if (mode === "html") {
|
||||||
|
console.log(rawHtml);
|
||||||
|
} else {
|
||||||
|
console.log(htmlToText(rawHtml));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imgs, vids } = extractMedia(rawHtml);
|
||||||
|
for (const u of imgs) console.log(`\n[IMAGE] ${u}`);
|
||||||
|
for (const u of vids) console.log(`\n[VIDEO] ${u}`);
|
||||||
|
|
||||||
|
console.log("\n---\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -6,6 +6,9 @@
|
|||||||
- Before editing, read files in full, especially if the read tool truncates them.
|
- Before editing, read files in full, especially if the read tool truncates them.
|
||||||
- Follow existing code style in touched files (naming, type usage, control flow, and error handling patterns).
|
- Follow existing code style in touched files (naming, type usage, control flow, and error handling patterns).
|
||||||
|
|
||||||
|
## Files to never commit
|
||||||
|
- **NEVER** commit Eclipse settings files (`.settings/`, `.classpath`, `.project`). These are IDE-specific and must not be checked in. If `git status` shows changes to these files, revert them before committing.
|
||||||
|
|
||||||
## Git commit subject prefix (required)
|
## Git commit subject prefix (required)
|
||||||
Every commit subject must start with a runtime prefix.
|
Every commit subject must start with a runtime prefix.
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user