castle-web-cli 0.4.109 → 0.4.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-failures.d.ts +1 -1
- package/dist/agent-failures.js +2 -0
- package/dist/agent-prompts.js +1 -0
- package/dist/agent.js +69 -10
- package/dist/api.d.ts +24 -5
- package/dist/api.js +71 -9
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +9 -1
- package/dist/httpJson.d.ts +4 -0
- package/dist/httpJson.js +21 -0
- package/dist/ide.d.ts +2 -0
- package/dist/ide.js +34 -15
- package/dist/importBrowse.d.ts +21 -0
- package/dist/importBrowse.js +240 -0
- package/dist/imports.d.ts +21 -1
- package/dist/imports.js +96 -24
- package/dist/index.js +11 -4
- package/dist/list-decks.d.ts +3 -0
- package/dist/list-decks.js +44 -0
- package/dist/metering.d.ts +1 -0
- package/dist/metering.js +3 -0
- package/dist/save-deck.d.ts +3 -1
- package/dist/save-deck.js +7 -3
- package/dist/serve.js +10 -2
- package/dist/shell/assets/index-BbQN3p8P.css +1 -0
- package/dist/shell/assets/index-DIVtkGt8.js +144 -0
- package/dist/shell/favicon-16x16.png +0 -0
- package/dist/shell/favicon-32x32.png +0 -0
- package/dist/shell/favicon.ico +0 -0
- package/dist/shell/index.html +8 -2
- package/package.json +2 -2
- package/dist/shell/assets/index-CYG9z07_.js +0 -144
- package/dist/shell/assets/index-wU4ol--C.css +0 -1
package/dist/agent-failures.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type FailureKind = "config" | "limit" | "transient" | "no-work" | "spawn" | "timeout" | "exit";
|
|
2
|
-
export type ConfigReason = "no-key" | "bad-key" | "no-credits" | "unknown-model" | "no-tools" | "no-endpoints" | "flagged" | "context-length";
|
|
2
|
+
export type ConfigReason = "no-key" | "bad-key" | "no-credits" | "model-not-allowed" | "unknown-model" | "no-tools" | "no-endpoints" | "flagged" | "context-length";
|
|
3
3
|
export interface AgentFailure {
|
|
4
4
|
kind: FailureKind;
|
|
5
5
|
reason?: ConfigReason;
|
package/dist/agent-failures.js
CHANGED
|
@@ -150,6 +150,8 @@ function configCopy(failure) {
|
|
|
150
150
|
return "OpenRouter rejected this session's API key. The person running this session needs to check it -- reach out to Castle if you need help.";
|
|
151
151
|
case "no-credits":
|
|
152
152
|
return "OpenRouter is out of credits for this session's key. Reach out to Castle to top it up, or switch to a different model in settings.";
|
|
153
|
+
case "model-not-allowed":
|
|
154
|
+
return `${model} isn't available on this Castle account. Pick a different model in settings, or run it on your own API key or login.`;
|
|
153
155
|
case "unknown-model": {
|
|
154
156
|
const hint = failure.suggestion ? ` Did you mean "${failure.suggestion}"?` : "";
|
|
155
157
|
return `I can't use the model this session is set to -- OpenRouter doesn't recognize ${model}.${hint} Pick a different model in settings. If you think that model should work, reach out to Castle.`;
|
package/dist/agent-prompts.js
CHANGED
|
@@ -26,6 +26,7 @@ What a deck is: a normal web project served by vite -- index.html plus plain JS/
|
|
|
26
26
|
|
|
27
27
|
Hard rules:
|
|
28
28
|
- You NEVER edit files or run state-changing commands. All building and fixing happens through background task agents -- always hand the longer work to them.
|
|
29
|
+
- IMPORTS ARE YOURS, and they are the one exception to the rule above. Run \`castle-web list-decks --kind kits\` (also \`mine\` and \`saved\`) to see what this deck can import, and \`castle-web add-import <deckIdOrUrl>\` to add one -- both directly, not through a task. Do NOT claim you can list or import anything until you have actually run these; do not describe imports you have not looked at. \`list-decks\` prints one deck per line starting with the id \`add-import\` takes, and marks the ones this deck already has. A pasted castle.xyz deck link works in place of an id. Everything else about a deck's files still goes to task agents.
|
|
29
30
|
- You are the fast lane: get to your final reply as quickly as possible. When the user reports something broken, do NOT dig into the code to diagnose it first -- spawn a task whose job is to investigate AND fix it. Only read deck files when your reply itself needs them (answering a question about the deck, grounding a claim -- never make things up); never read as pre-work before spawning a task, and never read files just to learn conventions already covered by the Quick reference.
|
|
30
31
|
- Launch a SET of small steps the user tests one by one -- a pipeline, never one big task they wait on, never untestable fragments. One interacting mechanic = one task (paddle + ball + bricks = one playable core, not three). First step = the smallest genuinely playable thing; later steps build it out. Match breadth to ambition ("basic" = a few steps; "go wild" = many). You're optimizing the user's taste and feedback -- more small testable steps = more points where they steer it into something theirs.
|
|
31
32
|
- The whole goal: every piece of work TESTABLE in actual gameplay ASAP. Start every task as early as possible and run them in PARALLEL. Do NOT break tasks down by which files they touch, and never add \`after:\` just to avoid two tasks editing the same file -- tasks make surgical edits and overlap fine. The only real dependency between tasks is INFORMATION: a task is blocked only when it needs a fact it does not yet have.
|
package/dist/agent.js
CHANGED
|
@@ -92,6 +92,23 @@ function normalizeClaudeModel(value) {
|
|
|
92
92
|
? value
|
|
93
93
|
: null;
|
|
94
94
|
}
|
|
95
|
+
// A claude run is spawned with an alias (`--model fable`) but reaches the proxy
|
|
96
|
+
// as a concrete id (`claude-fable-5`), and it is the ids the proxy refuses. This
|
|
97
|
+
// is the one place that knows both, so the model picker and the pre-flight
|
|
98
|
+
// refusal can't disagree about which models a user actually has.
|
|
99
|
+
const CLAUDE_MODEL_ID_PREFIXES = {
|
|
100
|
+
sonnet: "claude-sonnet-",
|
|
101
|
+
opus: "claude-opus-",
|
|
102
|
+
fable: "claude-fable-",
|
|
103
|
+
};
|
|
104
|
+
// Blocked when the two prefixes agree as far as the shorter one goes: the proxy
|
|
105
|
+
// may name a family ("claude-fable-") or one model within it.
|
|
106
|
+
function claudeModelBlocked(model, budget) {
|
|
107
|
+
const id = CLAUDE_MODEL_ID_PREFIXES[model];
|
|
108
|
+
if (!id || !budget)
|
|
109
|
+
return false;
|
|
110
|
+
return budget.blockedModelPrefixes.some((p) => p.startsWith(id) || id.startsWith(p));
|
|
111
|
+
}
|
|
95
112
|
// Free-form, so validation is just "non-empty, not absurdly long" (guards
|
|
96
113
|
// against a stray huge paste landing in settings.json / the CLI argv).
|
|
97
114
|
const OPENROUTER_MODEL_MAX_LEN = 200;
|
|
@@ -1654,14 +1671,29 @@ function anyRoleIsCastlePaid(settings) {
|
|
|
1654
1671
|
return (runIsCastlePaid(settings.router, settings.routerClaudeModel, null) ||
|
|
1655
1672
|
runIsCastlePaid(settings.tasks, settings.tasksClaudeModel, null));
|
|
1656
1673
|
}
|
|
1657
|
-
// The proxy 403s a spent-out user
|
|
1658
|
-
//
|
|
1659
|
-
//
|
|
1660
|
-
|
|
1674
|
+
// The proxy 403s a spent-out user -- or one who asked for a model they don't
|
|
1675
|
+
// have -- mid-stream, which a CLI surfaces as a generic provider error after a
|
|
1676
|
+
// spawn. Asking first turns either into one sentence and no spawn. Fails open on
|
|
1677
|
+
// every non-answer: the proxy is the real backstop.
|
|
1678
|
+
//
|
|
1679
|
+
// Only the claude aliases are checked, which is exactly what the picker offers.
|
|
1680
|
+
// A free-form OpenRouter slug naming a restricted model is left to the proxy:
|
|
1681
|
+
// resolving an arbitrary slug to what it bills as is its job, not the editor's.
|
|
1682
|
+
async function castleSpendRefusal(backend, claudeModel, orAuth) {
|
|
1661
1683
|
if (!runIsCastlePaid(backend, claudeModel, orAuth))
|
|
1662
1684
|
return null;
|
|
1663
1685
|
const budget = await fetchBudget();
|
|
1664
|
-
if (!budget
|
|
1686
|
+
if (!budget)
|
|
1687
|
+
return null;
|
|
1688
|
+
if (claudeModelBlocked(claudeModel, budget)) {
|
|
1689
|
+
return {
|
|
1690
|
+
kind: "config",
|
|
1691
|
+
reason: "model-not-allowed",
|
|
1692
|
+
detail: `${claudeModel} is not available on this Castle account`,
|
|
1693
|
+
model: claudeModel,
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
if (!budget.blocked)
|
|
1665
1697
|
return null;
|
|
1666
1698
|
return {
|
|
1667
1699
|
kind: "limit",
|
|
@@ -1673,6 +1705,33 @@ async function budgetRefusal(backend, claudeModel, orAuth) {
|
|
|
1673
1705
|
// finished run already refreshes. This is for the spend this serve never sees
|
|
1674
1706
|
// -- a `claude` invoked straight from the sandbox terminal.
|
|
1675
1707
|
const USAGE_POLL_MS = 60_000;
|
|
1708
|
+
const PICKER_CLAUDE_MODELS = ["sonnet", "opus", "fable"];
|
|
1709
|
+
/**
|
|
1710
|
+
* Which of the picker's claude models this editor can't use. Gated on the
|
|
1711
|
+
* ANTHROPIC credential specifically, not on `anyRoleIsCastlePaid` (which draws
|
|
1712
|
+
* the usage bar): those two disagree exactly when one role runs on Castle's
|
|
1713
|
+
* OpenRouter key -- or on Castle's cursor key -- while the user's own Anthropic
|
|
1714
|
+
* key or login covers every claude run. Those runs never reach the proxy, so
|
|
1715
|
+
* nothing about them is Castle's to restrict, and the picker must keep offering
|
|
1716
|
+
* the model. A claude run on a fixed alias always resolves through
|
|
1717
|
+
* resolveAnthropicAuth, so no role's settings enter into this.
|
|
1718
|
+
*/
|
|
1719
|
+
function blockedClaudeModels(budget) {
|
|
1720
|
+
if (resolveAnthropicAuth().mode !== "proxy")
|
|
1721
|
+
return [];
|
|
1722
|
+
return PICKER_CLAUDE_MODELS.filter((m) => claudeModelBlocked(m, budget));
|
|
1723
|
+
}
|
|
1724
|
+
function usageFrame(budget) {
|
|
1725
|
+
if (!budget)
|
|
1726
|
+
return null;
|
|
1727
|
+
return {
|
|
1728
|
+
usedMicros: budget.usedMicros,
|
|
1729
|
+
limitMicros: budget.limitMicros,
|
|
1730
|
+
resetAtMs: budget.resetAtMs,
|
|
1731
|
+
blocked: budget.blocked,
|
|
1732
|
+
blockedClaudeModels: blockedClaudeModels(budget),
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1676
1735
|
/**
|
|
1677
1736
|
* The editor's daily-usage feed, pushed over the agent socket exactly the way
|
|
1678
1737
|
* settings are: the current value rides `hello`, and a change is broadcast.
|
|
@@ -1689,7 +1748,7 @@ const USAGE_POLL_MS = 60_000;
|
|
|
1689
1748
|
function createUsageFeed(opts) {
|
|
1690
1749
|
let latest = null;
|
|
1691
1750
|
async function refreshAsync() {
|
|
1692
|
-
const next = opts.castlePaid() ? await fetchBudget() : null;
|
|
1751
|
+
const next = usageFrame(opts.castlePaid() ? await fetchBudget() : null);
|
|
1693
1752
|
if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
|
|
1694
1753
|
return;
|
|
1695
1754
|
latest = next;
|
|
@@ -1725,11 +1784,11 @@ async function runAgentTurn(opts) {
|
|
|
1725
1784
|
// Deterministic config errors stop here: nothing spawned, no request issued,
|
|
1726
1785
|
// nothing billed. Returned (not thrown) because the callers' catch paths
|
|
1727
1786
|
// emit generic "something went wrong" copy, which would bury the specific
|
|
1728
|
-
// reason this pre-flight exists to produce.
|
|
1729
|
-
// same shape of answer, and comes second so a misconfigured
|
|
1730
|
-
// reported as misconfigured.
|
|
1787
|
+
// reason this pre-flight exists to produce. What Castle's spend policy
|
|
1788
|
+
// refuses is the same shape of answer, and comes second so a misconfigured
|
|
1789
|
+
// run is still reported as misconfigured.
|
|
1731
1790
|
const failure = (await preflightOpenrouterRun({ ...opts, orAuth })) ??
|
|
1732
|
-
(await
|
|
1791
|
+
(await castleSpendRefusal(opts.backend, opts.claudeModel, orAuth));
|
|
1733
1792
|
if (failure) {
|
|
1734
1793
|
return {
|
|
1735
1794
|
ok: false,
|
package/dist/api.d.ts
CHANGED
|
@@ -29,14 +29,33 @@ export interface MeProfile {
|
|
|
29
29
|
} | null;
|
|
30
30
|
}
|
|
31
31
|
export declare function me(): Promise<MeProfile | null>;
|
|
32
|
-
export interface
|
|
32
|
+
export interface DeckRow {
|
|
33
33
|
deckId: string;
|
|
34
|
-
title: string;
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
title: string | null;
|
|
35
|
+
creator: {
|
|
36
|
+
username: string;
|
|
37
37
|
} | null;
|
|
38
|
+
initialCard: {
|
|
39
|
+
backgroundImage: {
|
|
40
|
+
smallUrl: string | null;
|
|
41
|
+
} | null;
|
|
42
|
+
} | null;
|
|
43
|
+
parentDeck: {
|
|
44
|
+
creator: {
|
|
45
|
+
username: string;
|
|
46
|
+
} | null;
|
|
47
|
+
} | null;
|
|
48
|
+
}
|
|
49
|
+
export declare function myDecks(): Promise<DeckRow[]>;
|
|
50
|
+
export declare function feedDecks(feedId: string, limit: number): Promise<DeckRow[]>;
|
|
51
|
+
export interface PlaylistSummary {
|
|
52
|
+
playlistId: string;
|
|
53
|
+
title: string;
|
|
38
54
|
}
|
|
39
|
-
export declare function
|
|
55
|
+
export declare function myPlaylists(userId: string, limit: number): Promise<PlaylistSummary[]>;
|
|
56
|
+
export declare function isDeckIdShaped(value: string): boolean;
|
|
57
|
+
export declare function webDeckSourceVersions(deckIds: string[]): Promise<Map<string, string>>;
|
|
58
|
+
export declare function deckRows(deckIds: string[]): Promise<DeckRow[]>;
|
|
40
59
|
export declare function updateCardAndDeckV2(deck: Record<string, unknown>, card: Record<string, unknown>): Promise<{
|
|
41
60
|
deckId: string;
|
|
42
61
|
cardId: string;
|
package/dist/api.js
CHANGED
|
@@ -51,20 +51,82 @@ export async function me() {
|
|
|
51
51
|
return null;
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
// Everything an import-picker row shows for one deck: what it is, whose it is,
|
|
55
|
+
// what it looks like, and who it was remixed from. One fragment, so the three
|
|
56
|
+
// list queries below can't drift apart.
|
|
57
|
+
const DECK_ROW_FIELDS = `
|
|
58
|
+
deckId
|
|
59
|
+
title
|
|
60
|
+
creator { username }
|
|
61
|
+
initialCard { backgroundImage { smallUrl } }
|
|
62
|
+
parentDeck { creator { username } }
|
|
63
|
+
`;
|
|
64
|
+
// The signed-in user's own decks, in the server's order (newest first).
|
|
65
|
+
// Unbounded -- an account can hold hundreds -- so callers page through it
|
|
66
|
+
// themselves rather than the server guessing a cutoff.
|
|
54
67
|
export async function myDecks() {
|
|
55
|
-
const data = await graphql(`query {
|
|
56
|
-
me {
|
|
57
|
-
decks {
|
|
58
|
-
deckId
|
|
59
|
-
title
|
|
60
|
-
initialCard { cardId }
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}`);
|
|
68
|
+
const data = await graphql(`query { me { decks { ${DECK_ROW_FIELDS} } } }`);
|
|
64
69
|
handleAPIError(data);
|
|
65
70
|
const meData = data.data?.me;
|
|
66
71
|
return meData?.decks ?? [];
|
|
67
72
|
}
|
|
73
|
+
// Any explore feed, as deck rows. `bookmarks` is the saved-decks feed;
|
|
74
|
+
// `playlist:<id>` is one playlist's decks.
|
|
75
|
+
export async function feedDecks(feedId, limit) {
|
|
76
|
+
const data = await graphql(`query($feedId: ID!, $limit: Int) {
|
|
77
|
+
paginateFeed(feedId: $feedId, limit: $limit) { ${DECK_ROW_FIELDS} }
|
|
78
|
+
}`, { feedId, limit });
|
|
79
|
+
handleAPIError(data);
|
|
80
|
+
return pickData(data, 'paginateFeed') ?? [];
|
|
81
|
+
}
|
|
82
|
+
// The user's playlists. Their bookmarks playlist is one of these (id
|
|
83
|
+
// `bookmarks-<userId>`), so a caller listing both has to dedupe.
|
|
84
|
+
export async function myPlaylists(userId, limit) {
|
|
85
|
+
const data = await graphql(`query($userId: ID!, $limit: Int) {
|
|
86
|
+
playlistsForUser(userId: $userId, limit: $limit) {
|
|
87
|
+
items { playlistId title }
|
|
88
|
+
}
|
|
89
|
+
}`, { userId, limit });
|
|
90
|
+
handleAPIError(data);
|
|
91
|
+
const result = data.data?.playlistsForUser;
|
|
92
|
+
return (result?.items ?? []).filter((p) => p && typeof p.playlistId === 'string');
|
|
93
|
+
}
|
|
94
|
+
// Deck ids are server-issued, but this one builds a query string out of them,
|
|
95
|
+
// so anything that isn't id-shaped is dropped rather than interpolated.
|
|
96
|
+
const DECK_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
97
|
+
export function isDeckIdShaped(value) {
|
|
98
|
+
return DECK_ID_RE.test(value);
|
|
99
|
+
}
|
|
100
|
+
// Ask the same per-deck question about many decks in ONE request, by aliasing
|
|
101
|
+
// the field once per id. The picker asks these about every candidate row, and a
|
|
102
|
+
// request per deck would be dozens of round trips -- 200 aliases answer in about
|
|
103
|
+
// half a second. Results come back in alias order; a deck the server won't
|
|
104
|
+
// answer for (deleted, not visible) is simply absent.
|
|
105
|
+
async function aliasedDeckQuery(deckIds, field) {
|
|
106
|
+
const ids = deckIds.filter(isDeckIdShaped);
|
|
107
|
+
if (ids.length === 0)
|
|
108
|
+
return [];
|
|
109
|
+
const aliases = ids.map((id, i) => `a${i}: ${field(id)}`).join('\n');
|
|
110
|
+
const data = await graphql(`query {\n${aliases}\n}`);
|
|
111
|
+
return Object.values(data.data ?? {}).filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
// Which of these decks have web source on the server, and at what version.
|
|
114
|
+
// Having source is what makes a deck importable, so this is the picker's filter.
|
|
115
|
+
export async function webDeckSourceVersions(deckIds) {
|
|
116
|
+
const sources = await aliasedDeckQuery(deckIds, (id) => `webDeckSource(deckId: "${id}") { deckId updatedAt }`);
|
|
117
|
+
const found = new Map();
|
|
118
|
+
for (const source of sources) {
|
|
119
|
+
if (source.deckId && typeof source.updatedAt === 'string') {
|
|
120
|
+
found.set(source.deckId, source.updatedAt);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
// Named decks as picker rows -- for a deck someone pasted a link to, and for
|
|
126
|
+
// the kits list when it is a fixed set of decks rather than a playlist.
|
|
127
|
+
export async function deckRows(deckIds) {
|
|
128
|
+
return aliasedDeckQuery(deckIds, (id) => `deck(deckId: "${id}") { ${DECK_ROW_FIELDS} }`);
|
|
129
|
+
}
|
|
68
130
|
export async function updateCardAndDeckV2(deck, card) {
|
|
69
131
|
const data = await graphql(`mutation($deck: DeckInput!, $card: CardInput!) {
|
|
70
132
|
updateCardAndDeckV2(deck: $deck, card: $card) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const COMMON_INSTRUCTIONS = "## Assets (every deck)\n\n- **Load static assets (drawings, audio, etc.) through the bundler \u2014 never runtime-`fetch` a loose file path.** Use a static `import`, `import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so `fetch('drawings/qb.svg')` looks like it works locally \u2014 but `save-deck` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw `fetch`.\n\n## Touch controls (every deck)\n\n- **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch \u2014 direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics \u2014 a game with no directional movement should have no movement controls at all.\n\n## Fit the card (every deck)\n\n- **The deck plays inside a fixed 5:7 portrait card, not the full window.** The card is sized to fit the screen (at most about 450x630px), clips overflow, and does not scroll. Design the whole layout to fit inside that portrait box: size UI relative to the card with percentages, flex/grid, `min()`, `clamp()`, or viewport-relative units instead of fixed tall panels. Let playfields scale down on smaller cards rather than overflowing; anything outside the card edges is cut off. The SDK exports `CARD_RATIO` (= 5 / 7) if you need the exact ratio.\n- **Hand-rolled `<canvas>` elements must account for devicePixelRatio, or the game looks blurry on phones.** Size the backing store to the CSS layout size times `devicePixelRatio` (e.g. `canvas.width = rect.width * dpr`), keep the CSS width/height as the layout size, and scale the 2D context (`ctx.scale(dpr, dpr)`) so drawing code stays in CSS units \u2014 re-apply on resize. Kit decks don't need to do this by hand; the kit's engine already configures its canvas for DPR.\n - Exception: deliberate pixel art wants a fixed low-resolution backing store with `image-rendering: pixelated` CSS instead \u2014 don't DPR-scale that; the crisp chunky look is the point.\n";
|
|
1
|
+
export declare const COMMON_INSTRUCTIONS = "## Imports are read-only (every deck)\n\n- **`imports/` holds other decks' files and is locked read-only on disk.** Files are mode 0444 and directories 0555, so any shell command that writes, moves, or deletes inside `imports/` fails with `EACCES` / \"Permission denied\" \u2014 that is the lock working, not a broken checkout. Do not `chmod` around it, do not `sudo`, and do not retry the command a different way.\n- **Change an import through the CLI, never the filesystem.** `castle-web add-import <deckIdOrUrl>` adds one and `castle-web update-import [alias]` re-fetches it (`--check` to see if it is outdated, `--revert` to undo). These handle the unlock/relock themselves.\n- **`castle-web list-decks [--kind mine|saved|kits]` is how you find out WHAT can be imported** \u2014 one deck per line, starting with the id `add-import` takes, and marked when this deck already has it. `imports/` only shows what is here already; it is not a catalogue.\n- **To change an imported deck's behavior, copy what you need into this deck and edit the copy**, then reference your copy. Editing in place is not available, and an `update-import` would overwrite it anyway.\n- **Deleting the deck directory itself needs the lock released first** (`chmod -R u+w` on the deck dir) \u2014 that is the one legitimate reason to touch the modes, and only for a directory being thrown away.\n\n## Assets (every deck)\n\n- **Load static assets (drawings, audio, etc.) through the bundler \u2014 never runtime-`fetch` a loose file path.** Use a static `import`, `import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so `fetch('drawings/qb.svg')` looks like it works locally \u2014 but `save-deck` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw `fetch`.\n\n## Touch controls (every deck)\n\n- **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch \u2014 direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics \u2014 a game with no directional movement should have no movement controls at all.\n\n## Fit the card (every deck)\n\n- **The deck plays inside a fixed 5:7 portrait card, not the full window.** The card is sized to fit the screen (at most about 450x630px), clips overflow, and does not scroll. Design the whole layout to fit inside that portrait box: size UI relative to the card with percentages, flex/grid, `min()`, `clamp()`, or viewport-relative units instead of fixed tall panels. Let playfields scale down on smaller cards rather than overflowing; anything outside the card edges is cut off. The SDK exports `CARD_RATIO` (= 5 / 7) if you need the exact ratio.\n- **Hand-rolled `<canvas>` elements must account for devicePixelRatio, or the game looks blurry on phones.** Size the backing store to the CSS layout size times `devicePixelRatio` (e.g. `canvas.width = rect.width * dpr`), keep the CSS width/height as the layout size, and scale the 2D context (`ctx.scale(dpr, dpr)`) so drawing code stays in CSS units \u2014 re-apply on resize. Kit decks don't need to do this by hand; the kit's engine already configures its canvas for DPR.\n - Exception: deliberate pixel art wants a fixed low-resolution backing store with `image-rendering: pixelated` CSS instead \u2014 don't DPR-scale that; the crisp chunky look is the point.\n";
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
// deck's CLAUDE.md, regardless of kit (or no kit). Single source of truth —
|
|
3
3
|
// edit here, not in the kits. Keep it truly kit-agnostic; kit-specific rules
|
|
4
4
|
// (e.g. Space being reserved for play/stop) live in each kit's own CLAUDE.md.
|
|
5
|
-
export const COMMON_INSTRUCTIONS = `##
|
|
5
|
+
export const COMMON_INSTRUCTIONS = `## Imports are read-only (every deck)
|
|
6
|
+
|
|
7
|
+
- **\`imports/\` holds other decks' files and is locked read-only on disk.** Files are mode 0444 and directories 0555, so any shell command that writes, moves, or deletes inside \`imports/\` fails with \`EACCES\` / "Permission denied" — that is the lock working, not a broken checkout. Do not \`chmod\` around it, do not \`sudo\`, and do not retry the command a different way.
|
|
8
|
+
- **Change an import through the CLI, never the filesystem.** \`castle-web add-import <deckIdOrUrl>\` adds one and \`castle-web update-import [alias]\` re-fetches it (\`--check\` to see if it is outdated, \`--revert\` to undo). These handle the unlock/relock themselves.
|
|
9
|
+
- **\`castle-web list-decks [--kind mine|saved|kits]\` is how you find out WHAT can be imported** — one deck per line, starting with the id \`add-import\` takes, and marked when this deck already has it. \`imports/\` only shows what is here already; it is not a catalogue.
|
|
10
|
+
- **To change an imported deck's behavior, copy what you need into this deck and edit the copy**, then reference your copy. Editing in place is not available, and an \`update-import\` would overwrite it anyway.
|
|
11
|
+
- **Deleting the deck directory itself needs the lock released first** (\`chmod -R u+w\` on the deck dir) — that is the one legitimate reason to touch the modes, and only for a directory being thrown away.
|
|
12
|
+
|
|
13
|
+
## Assets (every deck)
|
|
6
14
|
|
|
7
15
|
- **Load static assets (drawings, audio, etc.) through the bundler — never runtime-\`fetch\` a loose file path.** Use a static \`import\`, \`import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })\`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so \`fetch('drawings/qb.svg')\` looks like it works locally — but \`save-deck\` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw \`fetch\`.
|
|
8
16
|
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
export declare function sendJson(res: http.ServerResponse, status: number, body: unknown): void;
|
|
3
|
+
export declare function readRequestBody(req: http.IncomingMessage): Promise<string>;
|
|
4
|
+
export declare function errorMessage(err: unknown): string;
|
package/dist/httpJson.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The two things every JSON endpoint on the serve needs: read a request body,
|
|
2
|
+
// write a response. Shared by the files API and the import API so they answer
|
|
3
|
+
// in the same shape (and so neither grows its own copy).
|
|
4
|
+
export function sendJson(res, status, body) {
|
|
5
|
+
res.writeHead(status, {
|
|
6
|
+
"content-type": "application/json; charset=utf-8",
|
|
7
|
+
"cache-control": "no-store",
|
|
8
|
+
});
|
|
9
|
+
res.end(JSON.stringify(body));
|
|
10
|
+
}
|
|
11
|
+
export function readRequestBody(req) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const chunks = [];
|
|
14
|
+
req.on("data", (c) => chunks.push(c));
|
|
15
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
16
|
+
req.on("error", reject);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function errorMessage(err) {
|
|
20
|
+
return err instanceof Error ? err.message : String(err);
|
|
21
|
+
}
|
package/dist/ide.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Duplex } from "stream";
|
|
|
3
3
|
import { type RawData } from "ws";
|
|
4
4
|
export declare const IDE_ASSET_PREFIX = "/__castle/ide/";
|
|
5
5
|
export declare const PTY_WS_PATH = "/__castle/pty";
|
|
6
|
+
export declare const FAVICON_FILES: string[];
|
|
7
|
+
export declare const FAVICON_LINK_TAGS: string;
|
|
6
8
|
export declare const FILES_API_PREFIX = "/__castle/files/";
|
|
7
9
|
export declare function rawDataToString(data: RawData): string;
|
|
8
10
|
export interface IdeServer {
|
package/dist/ide.js
CHANGED
|
@@ -15,6 +15,8 @@ import headlessPkg from "@xterm/headless";
|
|
|
15
15
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
16
16
|
import { WebSocketServer } from "ws";
|
|
17
17
|
import { IMPORTS_DIR, importStatuses, updateImport } from "./imports.js";
|
|
18
|
+
import { IMPORT_API_PREFIX, handleImportApi } from "./importBrowse.js";
|
|
19
|
+
import { readRequestBody, sendJson } from "./httpJson.js";
|
|
18
20
|
import { envForUserShell, installCliShims } from "./byo-auth.js";
|
|
19
21
|
const HeadlessTerminal = headlessPkg.Terminal;
|
|
20
22
|
const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -33,12 +35,19 @@ const SHELL_MIME = {
|
|
|
33
35
|
".json": "application/json; charset=utf-8",
|
|
34
36
|
".svg": "image/svg+xml",
|
|
35
37
|
".png": "image/png",
|
|
38
|
+
".ico": "image/x-icon",
|
|
36
39
|
".jpg": "image/jpeg",
|
|
37
40
|
".woff": "font/woff",
|
|
38
41
|
".woff2": "font/woff2",
|
|
39
42
|
".ttf": "font/ttf",
|
|
40
43
|
".map": "application/json; charset=utf-8",
|
|
41
44
|
};
|
|
45
|
+
// Does the deck serve this root-level file itself? Vite serves both the deck
|
|
46
|
+
// root and its `public/` dir at `/`, so either location counts.
|
|
47
|
+
function deckHasFile(deckDir, name) {
|
|
48
|
+
return (fs.existsSync(path.join(deckDir, name)) ||
|
|
49
|
+
fs.existsSync(path.join(deckDir, "public", name)));
|
|
50
|
+
}
|
|
42
51
|
// Serve a file from the bundled shell dir, guarding against path traversal.
|
|
43
52
|
function serveShellFile(res, asset) {
|
|
44
53
|
const rel = path.normalize(asset).replace(/^(\.\.[/\\])+/, "");
|
|
@@ -65,6 +74,22 @@ function serveShellFile(res, asset) {
|
|
|
65
74
|
// upgrade handler on Vite's HTTP server).
|
|
66
75
|
export const IDE_ASSET_PREFIX = "/__castle/ide/";
|
|
67
76
|
export const PTY_WS_PATH = "/__castle/pty";
|
|
77
|
+
// Castle's favicon (the same files castle.xyz serves), shipped in the shell
|
|
78
|
+
// bundle and also served from the origin root so every page under the serve
|
|
79
|
+
// gets it -- the shell at `/`, the deck page at `/index.html`, and the
|
|
80
|
+
// browser's implicit `/favicon.ico` probe. The deck wins if it ships its own.
|
|
81
|
+
export const FAVICON_FILES = [
|
|
82
|
+
"favicon.ico",
|
|
83
|
+
"favicon-16x16.png",
|
|
84
|
+
"favicon-32x32.png",
|
|
85
|
+
];
|
|
86
|
+
// `<link rel="icon">` tags for the root-served favicons, injected into the deck
|
|
87
|
+
// page (see serve.ts). The shell's own tags live in `src/shell/index.html`.
|
|
88
|
+
export const FAVICON_LINK_TAGS = [
|
|
89
|
+
'<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />',
|
|
90
|
+
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
|
|
91
|
+
'<link rel="icon" href="/favicon.ico" sizes="any" />',
|
|
92
|
+
].join("\n ");
|
|
68
93
|
// Builtin Files + code-editor panels talk to the deck through these endpoints
|
|
69
94
|
// (the shell no longer routes file browsing / code editing through the kit
|
|
70
95
|
// iframe). `list`/`read`/`write` operate on files within the deck dir;
|
|
@@ -300,21 +325,6 @@ function filterImportedFiles(deckDir, imported) {
|
|
|
300
325
|
}
|
|
301
326
|
return out;
|
|
302
327
|
}
|
|
303
|
-
function sendJson(res, status, body) {
|
|
304
|
-
res.writeHead(status, {
|
|
305
|
-
"content-type": "application/json; charset=utf-8",
|
|
306
|
-
"cache-control": "no-store",
|
|
307
|
-
});
|
|
308
|
-
res.end(JSON.stringify(body));
|
|
309
|
-
}
|
|
310
|
-
function readRequestBody(req) {
|
|
311
|
-
return new Promise((resolve, reject) => {
|
|
312
|
-
const chunks = [];
|
|
313
|
-
req.on("data", (c) => chunks.push(c));
|
|
314
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
315
|
-
req.on("error", reject);
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
328
|
function withJsonBody(req, res, handler) {
|
|
319
329
|
void (async () => {
|
|
320
330
|
let body;
|
|
@@ -782,12 +792,21 @@ export function createIdeServer(opts) {
|
|
|
782
792
|
// `/` -> the shell's index.html; `/__castle/ide/<asset>` -> bundle assets.
|
|
783
793
|
if (reqPath === "/")
|
|
784
794
|
return serveShellFile(res, "index.html");
|
|
795
|
+
// Root-served favicons, unless the deck ships its own (then fall through
|
|
796
|
+
// to Vite, which serves the deck's file).
|
|
797
|
+
const favicon = FAVICON_FILES.find((name) => reqPath === `/${name}`);
|
|
798
|
+
if (favicon && !deckHasFile(deckDir, favicon)) {
|
|
799
|
+
return serveShellFile(res, favicon);
|
|
800
|
+
}
|
|
785
801
|
if (reqPath.startsWith(IDE_ASSET_PREFIX)) {
|
|
786
802
|
return serveShellFile(res, reqPath.slice(IDE_ASSET_PREFIX.length) || "index.html");
|
|
787
803
|
}
|
|
788
804
|
if (reqPath.startsWith(FILES_API_PREFIX)) {
|
|
789
805
|
return handleFilesApi(deckDir, req, res, reqPath);
|
|
790
806
|
}
|
|
807
|
+
if (reqPath.startsWith(IMPORT_API_PREFIX)) {
|
|
808
|
+
return handleImportApi(deckDir, req, res, reqPath);
|
|
809
|
+
}
|
|
791
810
|
return false;
|
|
792
811
|
}
|
|
793
812
|
function shutdown() {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
export declare const IMPORT_API_PREFIX = "/__castle/import/";
|
|
3
|
+
export interface ImportCandidate {
|
|
4
|
+
deckId: string;
|
|
5
|
+
title: string;
|
|
6
|
+
creator: string | null;
|
|
7
|
+
imageUrl: string | null;
|
|
8
|
+
parentCreator: string | null;
|
|
9
|
+
hasSource: boolean;
|
|
10
|
+
imported: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ImportList {
|
|
13
|
+
signedIn: boolean;
|
|
14
|
+
decks: ImportCandidate[];
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
}
|
|
17
|
+
export type ImportTab = "mine" | "saved" | "kits";
|
|
18
|
+
export declare function listImportable(deckDir: string, tab: ImportTab): Promise<ImportList>;
|
|
19
|
+
export declare function resolveDeckRef(deckDir: string, ref: string): Promise<ImportCandidate>;
|
|
20
|
+
export declare function sourceFileCount(deckId: string): Promise<number | null>;
|
|
21
|
+
export declare function handleImportApi(deckDir: string, req: http.IncomingMessage, res: http.ServerResponse, reqPath: string): boolean;
|