Skip to content

Commit 19c85ad

Browse files
0xLeifclaude
andauthored
fix: align with fledge 1.0 CLI and JSON shapes (#3)
Fledge 1.0 changed list-style JSON envelopes (`{plugins:[]}`, `{lanes:[]}`, `{results:[]}`), removed the top-level `fledge search` command (now `templates search`), dropped `--json` from `config list`, renamed `lanes` step count to `step_count`, and now requires `--yes` for plugin install in non-interactive mode. - Unwrap envelopes server-side so /plugins, /lanes, /templates, and /plugins/search keep returning plain arrays. - /templates → `templates search --json`. - /config → parse the human-readable `config list` table into `{path, sections:[{name, entries:[{key, value, help, unset}]}]}`. - /introspect and /info → return `subcommands` as a flat array. - /plugins/install → pass `--yes`. - /lanes → run in projectCwd(), return [] when fledge.toml or lanes are absent instead of erroring. - project.ts → unwrap lanes and map step_count → steps. - Lanes page renders the new schema (description, step count, fail-fast, trust tier). - Config page renders sections with help text and unset state. - Sidebar HUB version is now dynamic via /api/info `hubVersion`. Bump to 0.5.0. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 830a87d commit 19c85ad

8 files changed

Lines changed: 230 additions & 39 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "fledge-plugin-hub",
3-
"version": "0.4.0",
3+
"version": "0.5.0",
44
"description": "Local web dashboard for browsing and managing fledge templates, plugins, lanes, and config",
55
"type": "module",
66
"scripts": {

plugin.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[plugin]
22
name = "fledge-plugin-hub"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
description = "Local web dashboard for browsing and managing fledge templates, plugins, lanes, and config"
55
author = "CorvidLabs"
66

public/app.js

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -559,25 +559,25 @@ const loaders = {
559559
return;
560560
}
561561

562-
const lanes = Array.isArray(data)
563-
? data
564-
: typeof data === "object"
565-
? Object.entries(data).map(([name, steps]) => ({ name, steps }))
566-
: [];
562+
const lanes = Array.isArray(data) ? data : [];
567563

568564
if (lanes.length === 0) {
569565
html(list, renderEmpty('No lanes defined. Add lanes to your <code>fledge.toml</code>'));
570566
} else {
571-
html(list, lanes.map((l) => `
572-
<div class="lane-card">
573-
<div class="lane-name">${escape(l.name)}</div>
574-
<div class="lane-steps">
575-
${(Array.isArray(l.steps) ? l.steps : [])
576-
.map((s, i) => `${i > 0 ? '<span class="lane-step-arrow">&rarr;</span>' : ""}<span class="lane-step">${escape(typeof s === "string" ? s : s.name || JSON.stringify(s))}</span>`)
577-
.join("")}
567+
html(list, lanes.map((l) => {
568+
const stepCount = typeof l.step_count === "number" ? l.step_count : (typeof l.steps === "number" ? l.steps : null);
569+
return `
570+
<div class="lane-card">
571+
<div class="lane-name">${escape(l.name)}</div>
572+
${l.description ? `<div class="lane-desc">${escape(l.description)}</div>` : ""}
573+
<div class="lane-meta">
574+
${stepCount != null ? `<span class="badge badge-version">${stepCount} step${stepCount === 1 ? "" : "s"}</span>` : ""}
575+
${l.fail_fast ? `<span class="badge badge-team">fail-fast</span>` : ""}
576+
${l.trust_tier ? `<span class="badge badge-command">${escape(l.trust_tier)}</span>` : ""}
577+
</div>
578578
</div>
579-
</div>
580-
`).join(""));
579+
`;
580+
}).join(""));
581581
}
582582

583583
loaded.lanes = true;
@@ -593,17 +593,30 @@ const loaders = {
593593
return;
594594
}
595595

596-
const entries = typeof data === "object" && !Array.isArray(data) ? Object.entries(data) : [];
596+
const sections = Array.isArray(data?.sections) ? data.sections : [];
597+
const total = sections.reduce((n, s) => n + (s.entries?.length || 0), 0);
597598

598-
if (entries.length === 0) {
599+
if (total === 0) {
599600
html(list, renderEmpty("No config entries found"));
600601
} else {
601-
html(list, entries.map(([k, v]) => `
602-
<div class="config-entry">
603-
<span class="config-key">${escape(k)}</span>
604-
<span class="config-value">${escape(typeof v === "string" ? v : JSON.stringify(v))}</span>
602+
const path = data?.path
603+
? `<div class="config-path">${escape(data.path)}</div>`
604+
: "";
605+
const sectionsHtml = sections.map((s) => `
606+
<div class="config-section">
607+
<h3 class="config-section-name">${escape(s.name)}</h3>
608+
<div class="config-entries">
609+
${(s.entries || []).map((e) => `
610+
<div class="config-entry${e.unset ? " config-entry-unset" : ""}">
611+
<span class="config-key">${escape(e.key)}</span>
612+
<span class="config-value">${escape(e.value)}</span>
613+
${e.help ? `<span class="config-help">${escape(e.help)}</span>` : ""}
614+
</div>
615+
`).join("")}
616+
</div>
605617
</div>
606-
`).join(""));
618+
`).join("");
619+
html(list, `${path}${sectionsHtml}`);
607620
}
608621

609622
loaded.config = true;
@@ -755,5 +768,8 @@ $(".modal-close")?.addEventListener("click", () => {
755768
if (info?.version) {
756769
$("#fledge-version").textContent = info.version;
757770
}
771+
if (info?.hubVersion) {
772+
$("#hub-version").textContent = `v${info.hubVersion}`;
773+
}
758774
navigate("project");
759775
})();

public/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
</div>
5959
<div class="sidebar-meta">
6060
<span class="meta-key">HUB</span>
61-
<span class="meta-val">v0.4.0</span>
61+
<span class="meta-val" id="hub-version"></span>
6262
</div>
6363
</div>
6464
</nav>

public/style.css

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,13 @@ h3 {
845845
}
846846

847847
.lane-steps { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; }
848+
.lane-meta { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; margin-top: 0.55rem; }
849+
.lane-desc {
850+
color: var(--text-secondary);
851+
font-size: 0.8rem;
852+
margin-bottom: 0.55rem;
853+
line-height: 1.5;
854+
}
848855

849856
.lane-step {
850857
font-family: var(--font-mono);
@@ -866,23 +873,59 @@ h3 {
866873

867874
/* ============== CONFIG ============== */
868875

876+
.config-path {
877+
font-family: var(--font-mono);
878+
font-size: 0.72rem;
879+
color: var(--text-faint);
880+
letter-spacing: 0.04em;
881+
margin-bottom: 0.4rem;
882+
}
883+
884+
.config-section {
885+
display: flex;
886+
flex-direction: column;
887+
gap: 0.4rem;
888+
padding-top: 0.4rem;
889+
}
890+
891+
.config-section-name {
892+
font-family: var(--font-display);
893+
font-size: 0.95rem;
894+
font-weight: 500;
895+
color: var(--text-primary);
896+
letter-spacing: 0.04em;
897+
margin: 0;
898+
padding-bottom: 0.25rem;
899+
border-bottom: 1px solid var(--border);
900+
}
901+
902+
.config-entries { display: flex; flex-direction: column; gap: 0.4rem; }
903+
869904
.config-entry {
870905
background: var(--bg-surface);
871906
border: 1px solid var(--border);
872907
border-radius: var(--radius);
873908
padding: 0.7rem 1rem;
874-
display: flex;
875-
justify-content: space-between;
909+
display: grid;
910+
grid-template-columns: minmax(180px, 1fr) auto;
876911
align-items: baseline;
877-
gap: 1rem;
912+
gap: 0.4rem 1rem;
878913
font-family: var(--font-mono);
879914
font-size: 0.78rem;
880915
transition: border-color var(--transition-base);
881916
}
882917
.config-entry:hover { border-color: var(--border-bright); }
918+
.config-entry-unset { opacity: 0.6; }
883919

884920
.config-key { font-weight: 600; color: var(--text-primary); letter-spacing: 0.02em; }
885921
.config-value { color: var(--text-secondary); text-align: right; word-break: break-all; }
922+
.config-help {
923+
grid-column: 1 / -1;
924+
font-family: var(--font-sans);
925+
font-size: 0.72rem;
926+
color: var(--text-faint);
927+
line-height: 1.4;
928+
}
886929

887930
/* ============== TERMINAL OUTPUT ============== */
888931

src/api.ts

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,43 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
13
import { Hono } from "hono";
24
import { fledge, fledgeJson, projectCwd } from "./fledge";
35
import { browseAll, browsePlugins, browseTemplates, getRepoReadme } from "./github";
46
import { gatherProjectInfo, openInBrowser } from "./project";
7+
import { parseConfigList } from "./config";
8+
9+
const HUB_VERSION: string = (() => {
10+
try {
11+
const pkg = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8"));
12+
return typeof pkg.version === "string" ? pkg.version : "";
13+
} catch {
14+
return "";
15+
}
16+
})();
517

618
export const api = new Hono();
719

20+
// Fledge 1.0 wraps list-style JSON responses in an envelope:
21+
// plugins list -> { plugins: [...] }
22+
// lanes list -> { lanes: [...] }
23+
// plugins search / templates search -> { results: [...] }
24+
// Unwrap the envelope so the frontend sees a plain array.
25+
function unwrap<T>(value: unknown, key: string): T[] | unknown {
26+
if (value && typeof value === "object" && !Array.isArray(value)) {
27+
const obj = value as Record<string, unknown>;
28+
if (Array.isArray(obj[key])) return obj[key] as T[];
29+
if ("error" in obj) return value;
30+
}
31+
return value;
32+
}
33+
34+
function isEmptyLaneError(value: unknown): boolean {
35+
if (!value || typeof value !== "object") return false;
36+
const err = (value as { error?: unknown }).error;
37+
if (typeof err !== "string") return false;
38+
return err.includes("No fledge.toml") || err.includes("No lanes defined");
39+
}
40+
841
api.get("/project", async (c) => {
942
const info = await gatherProjectInfo();
1043
return c.json(info);
@@ -51,24 +84,30 @@ api.post("/project/open-repo", async (c) => {
5184

5285
api.get("/introspect", async (c) => {
5386
const result = await fledgeJson(["introspect", "--json"]);
87+
// Frontend expects an array of top-level commands.
88+
if (result && typeof result === "object" && Array.isArray((result as { subcommands?: unknown }).subcommands)) {
89+
return c.json((result as { subcommands: unknown[] }).subcommands);
90+
}
5491
return c.json(result);
5592
});
5693

5794
api.get("/plugins", async (c) => {
5895
const result = await fledgeJson(["plugins", "list", "--json"]);
59-
return c.json(result);
96+
return c.json(unwrap(result, "plugins"));
6097
});
6198

6299
api.get("/plugins/search", async (c) => {
63100
const query = c.req.query("q") || "";
64101
const result = await fledgeJson(["plugins", "search", query, "--json"]);
65-
return c.json(result);
102+
return c.json(unwrap(result, "results"));
66103
});
67104

68105
api.post("/plugins/install", async (c) => {
69106
const body = await c.req.json<{ source?: string; force?: boolean }>().catch(() => ({}));
70107
if (!body.source) return c.json({ error: "source required" }, 400);
71-
const args = ["plugins", "install", body.source, "--json"];
108+
// --yes is required because FLEDGE_NON_INTERACTIVE is set in the env and
109+
// install would otherwise bail on the trust-prompt.
110+
const args = ["plugins", "install", body.source, "--yes", "--json"];
72111
if (body.force) args.push("--force");
73112
const result = await fledge(args);
74113
return c.json({
@@ -106,18 +145,28 @@ api.post("/plugins/update", async (c) => {
106145
});
107146

108147
api.get("/templates", async (c) => {
109-
const result = await fledgeJson(["search", "--json"]);
110-
return c.json(result);
148+
// Fledge 1.0 moved this under `templates search` and there is no longer a
149+
// top-level `fledge search` command. See `fledge templates --help`.
150+
const result = await fledgeJson(["templates", "search", "--json"]);
151+
return c.json(unwrap(result, "results"));
111152
});
112153

113154
api.get("/lanes", async (c) => {
114-
const result = await fledgeJson(["lanes", "list", "--json"]);
115-
return c.json(result);
155+
// Lanes are project-scoped (declared in fledge.toml). Run in the project
156+
// cwd so a user launching `fledge hub` from a project sees their lanes.
157+
const cwd = projectCwd();
158+
const result = await fledgeJson(["lanes", "list", "--json"], { cwd });
159+
if (isEmptyLaneError(result)) return c.json([]);
160+
return c.json(unwrap(result, "lanes"));
116161
});
117162

118163
api.get("/config", async (c) => {
119-
const result = await fledgeJson(["config", "list", "--json"]);
120-
return c.json(result);
164+
// `fledge config list` does not accept --json in 1.0 — parse the table form.
165+
const result = await fledge(["config", "list"]);
166+
if (result.exitCode !== 0) {
167+
return c.json({ error: result.stderr || result.stdout, exitCode: result.exitCode });
168+
}
169+
return c.json(parseConfigList(result.stdout));
121170
});
122171

123172
api.get("/doctor", async (c) => {
@@ -133,9 +182,13 @@ api.get("/doctor", async (c) => {
133182
api.get("/info", async (c) => {
134183
const result = await fledgeJson(["introspect", "--json"]);
135184
const version = await fledge(["--version"]);
185+
const commands = result && typeof result === "object" && Array.isArray((result as { subcommands?: unknown }).subcommands)
186+
? (result as { subcommands: unknown[] }).subcommands
187+
: [];
136188
return c.json({
137189
version: version.stdout.trim(),
138-
commands: result,
190+
hubVersion: HUB_VERSION,
191+
commands,
139192
});
140193
});
141194

src/config.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
export interface ConfigEntry {
2+
section: string;
3+
key: string;
4+
value: string;
5+
help: string;
6+
unset: boolean;
7+
}
8+
9+
export interface ConfigList {
10+
path: string | null;
11+
sections: Array<{ name: string; entries: ConfigEntry[] }>;
12+
}
13+
14+
// Parses the human-readable output of `fledge config list`.
15+
//
16+
// Sample input:
17+
// * Config: /path/to/config.toml
18+
//
19+
// Defaults
20+
// defaults.author 0xLeif Author name for new projects
21+
// defaults.github_org (not set) GitHub org for new projects
22+
//
23+
// GitHub
24+
// github.token (not set) API token for GitHub operations
25+
export function parseConfigList(text: string): ConfigList {
26+
const lines = text.split("\n");
27+
let path: string | null = null;
28+
const sections: Array<{ name: string; entries: ConfigEntry[] }> = [];
29+
let current: { name: string; entries: ConfigEntry[] } | null = null;
30+
31+
for (const raw of lines) {
32+
if (!raw.trim()) continue;
33+
34+
const pathMatch = raw.match(/^\*\s*Config:\s*(.+)$/);
35+
if (pathMatch) {
36+
path = pathMatch[1].trim();
37+
continue;
38+
}
39+
40+
// Entry rows are dotted keys: `defaults.author value help`.
41+
const entryMatch = raw.match(/^\s+([a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+)\s{2,}(.+)$/i);
42+
if (entryMatch && current) {
43+
const key = entryMatch[1];
44+
const rest = entryMatch[2];
45+
// Split rest into value and help on 2+ spaces. Some values like
46+
// "(not set)" or "(none)" are still treated normally.
47+
const split = rest.match(/^(.+?)\s{2,}(.+)$/);
48+
const value = (split ? split[1] : rest).trim();
49+
const help = split ? split[2].trim() : "";
50+
current.entries.push({
51+
section: current.name,
52+
key,
53+
value,
54+
help,
55+
unset: value === "(not set)" || value === "(none)",
56+
});
57+
continue;
58+
}
59+
60+
// Section header: indented bare word, no dot, no separator value.
61+
const sectionMatch = raw.match(/^\s+([A-Z][A-Za-z0-9 /]*)\s*$/);
62+
if (sectionMatch) {
63+
current = { name: sectionMatch[1].trim(), entries: [] };
64+
sections.push(current);
65+
}
66+
}
67+
68+
return { path, sections };
69+
}

0 commit comments

Comments
 (0)