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 { 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 = {
|
||||
kind: "pr" | "issue";
|
||||
kind: "pr" | "issue" | "forum";
|
||||
url: string;
|
||||
forumId?: string;
|
||||
};
|
||||
|
||||
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 {
|
||||
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
||||
if (prMatch?.[1]) {
|
||||
return { kind: "pr", url: prMatch[1].trim() };
|
||||
let last: PromptMatch | undefined;
|
||||
|
||||
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);
|
||||
if (issueMatch?.[1]) {
|
||||
return { kind: "issue", url: issueMatch[1].trim() };
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
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(
|
||||
@ -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 label = match.kind === "pr" ? "PR" : "Issue";
|
||||
const label = match.kind === "pr" ? "PR" : match.kind === "issue" ? "Issue" : "Forum";
|
||||
const trimmedTitle = title?.trim();
|
||||
const fallbackName = `${label}: ${match.url}`;
|
||||
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
||||
@ -100,9 +129,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
void fetchMetadata(pi, match).then(({ title, authorText }) => {
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
@ -144,9 +171,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
void fetchMetadata(pi, match).then(({ title, authorText }) => {
|
||||
setWidget(ctx, match, title, authorText);
|
||||
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.
|
||||
- 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)
|
||||
Every commit subject must start with a runtime prefix.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user