@alashi/cli 0.1.0 → 0.2.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.
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
6
6
  import { extname, isAbsolute, join, resolve, sep } from "node:path";
7
7
  import { promisify } from "node:util";
8
- import { pathToFileURL } from "node:url";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { z } from "zod";
10
10
  import { Hono } from "hono";
11
11
  import { streamSSE } from "hono/streaming";
@@ -574,8 +574,10 @@ var Scheduler = class {
574
574
  //#endregion
575
575
  //#region src/settings-page.ts
576
576
  /**
577
- * Self-contained per-app settings page. All data comes from
577
+ * Self-contained per-app settings page. Config data comes from
578
578
  * GET /api/apps/:name/config at runtime; saving PUTs only changed keys.
579
+ * The read-only "App definition" section (instructions, agents, skills,
580
+ * allowed tools, MCP servers, jobs) comes from GET /api/apps/:name/manifest.
579
581
  * The app name is injected via a JSON-encoded script constant, never into
580
582
  * markup, so no escaping subtleties apply.
581
583
  */
@@ -586,42 +588,24 @@ function settingsPage(name) {
586
588
  <meta charset="utf-8">
587
589
  <meta name="viewport" content="width=device-width, initial-scale=1">
588
590
  <title>Settings</title>
591
+ <link rel="stylesheet" href="/assets/v1/system.css">
589
592
  <style>
590
- :root { --accent: #6d28d9; --bg: #f6f5f8; --card: #fff; --line: #e5e2eb; --muted: #6b6478; }
591
- * { box-sizing: border-box; }
592
- body { margin: 0; font: 15px/1.5 system-ui, sans-serif; background: var(--bg); color: #1d1a24; }
593
- header { background: #17141f; color: #fff; padding: 14px 24px; display: flex; align-items: center; gap: 12px; }
594
- header a { color: #cbc4dd; text-decoration: none; }
595
- main { max-width: 760px; margin: 24px auto; padding: 0 16px; }
596
- .banner { border-radius: 8px; padding: 10px 14px; margin-bottom: 16px; font-weight: 500; }
597
- .banner.ok { background: #e7f6ec; color: #16652f; }
598
- .banner.warn { background: #fdeeee; color: #9c2121; }
599
- .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; margin-bottom: 16px; }
600
- .card h2 { margin: 0; padding: 12px 16px; font-size: 15px; border-bottom: 1px solid var(--line);
601
- display: flex; align-items: center; justify-content: space-between; }
602
- .chip { font-size: 12px; font-weight: 600; border-radius: 999px; padding: 2px 10px; }
603
- .chip.on { background: #e7f6ec; color: #16652f; }
604
- .chip.off { background: #f1eef7; color: var(--muted); }
605
- .card .body { padding: 12px 16px 16px; }
606
- .field { margin-bottom: 12px; }
607
- .field label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 2px; }
608
- .field .desc { font-size: 12.5px; color: var(--muted); margin-bottom: 4px; }
609
- .field input { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px;
610
- font: inherit; }
611
- .field input:focus { outline: 2px solid var(--accent); border-color: transparent; }
612
- .req { color: #9c2121; }
613
- button { background: var(--accent); color: #fff; border: 0; border-radius: 6px;
614
- padding: 9px 18px; font: inherit; font-weight: 600; cursor: pointer; }
615
- button:disabled { opacity: 0.5; cursor: default; }
593
+ .card { margin-bottom: 16px; }
616
594
  #status { margin-left: 12px; color: var(--muted); }
595
+ #manifest-cards { margin-top: 32px; }
596
+ .ro-pre { white-space: pre-wrap; word-break: break-word; margin: 8px 0 0; }
597
+ .ro-item { margin-bottom: 12px; }
598
+ .ro-item:last-child { margin-bottom: 0; }
599
+ details summary { cursor: pointer; }
617
600
  </style>
618
601
  </head>
619
602
  <body>
620
- <header><a href="/">alashi</a> <span>/</span> <strong id="title"></strong> <span>settings</span></header>
621
- <main>
603
+ <header class="topbar"><a href="/">alashi</a> <span>/</span> <strong id="title"></strong> <span>settings</span></header>
604
+ <main class="narrow">
622
605
  <div id="banner" class="banner" hidden></div>
623
606
  <div id="cards"></div>
624
- <button id="save">Save changes</button><span id="status"></span>
607
+ <button id="save" class="primary">Save changes</button><span id="status"></span>
608
+ <div id="manifest-cards"></div>
625
609
  </main>
626
610
  <script>
627
611
  const APP = ${JSON.stringify(name)};
@@ -650,8 +634,36 @@ function displayValue(value) {
650
634
  return typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value);
651
635
  }
652
636
 
637
+ const ACRONYMS = new Set(['url', 'uri', 'api', 'id', 'ip', 'db', 'ssl', 'tls', 'ttl', 'http', 'https']);
638
+
639
+ function labelFor(key, decl) {
640
+ if (decl.label) return decl.label;
641
+ const words = key
642
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
643
+ .replace(/[_-]+/g, ' ')
644
+ .toLowerCase()
645
+ .split(' ');
646
+ return words
647
+ .map((w, i) => (ACRONYMS.has(w) ? w.toUpperCase() : i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w))
648
+ .join(' ');
649
+ }
650
+
653
651
  function field(key, decl, data) {
654
652
  const secret = decl.secret === true;
653
+ const isBool = !secret &&
654
+ (typeof decl.default === 'boolean' || typeof data.values[key] === 'boolean');
655
+ if (isBool) {
656
+ const input = el('input', {
657
+ type: 'checkbox',
658
+ onchange: () => state.dirty.set(key, { secret: false, input }),
659
+ });
660
+ input.checked = data.values[key] === true;
661
+ const labelChildren = [input, document.createTextNode(labelFor(key, decl))];
662
+ if (decl.required) labelChildren.push(el('span', { class: 'req', text: ' *' }));
663
+ const parts = [el('label', { class: 'check' }, labelChildren)];
664
+ if (decl.description) parts.push(el('div', { class: 'desc', text: decl.description }));
665
+ return el('div', { class: 'field' }, parts);
666
+ }
655
667
  const isSet = secret ? data.secretsSet.includes(key) : data.values[key] !== undefined;
656
668
  const input = el('input', {
657
669
  type: secret ? 'password' : 'text',
@@ -659,7 +671,7 @@ function field(key, decl, data) {
659
671
  placeholder: secret && isSet ? '(set — type to replace)' : '',
660
672
  oninput: () => state.dirty.set(key, { secret, input }),
661
673
  });
662
- const labelChildren = [document.createTextNode(key)];
674
+ const labelChildren = [document.createTextNode(labelFor(key, decl))];
663
675
  if (decl.required) labelChildren.push(el('span', { class: 'req', text: ' *' }));
664
676
  const parts = [el('label', {}, labelChildren)];
665
677
  if (decl.description) parts.push(el('div', { class: 'desc', text: decl.description }));
@@ -694,12 +706,27 @@ function render(data) {
694
706
  const cards = document.getElementById('cards');
695
707
  cards.replaceChildren();
696
708
  const integrationKeys = new Set(data.integrations.flatMap((i) => i.configKeys));
697
- const generalKeys = Object.keys(data.schema).filter((k) => !integrationKeys.has(k));
698
- if (generalKeys.length > 0) cards.append(card('General', generalKeys, data, undefined));
709
+
710
+ // Non-integration keys, sectioned by their declared group ("General" first).
711
+ const sections = new Map();
712
+ for (const key of Object.keys(data.schema)) {
713
+ if (integrationKeys.has(key)) continue;
714
+ const title = data.schema[key].group ?? 'General';
715
+ if (!sections.has(title)) sections.set(title, []);
716
+ sections.get(title).push(key);
717
+ }
718
+ if (sections.has('General')) cards.append(card('General', sections.get('General'), data, undefined));
719
+ for (const [title, keys] of sections) {
720
+ if (title !== 'General') cards.append(card(title, keys, data, undefined));
721
+ }
722
+
723
+ // Integration cards, titled by the keys' shared group when declared.
699
724
  for (const integration of data.integrations) {
700
725
  if (integration.configKeys.length === 0) continue;
726
+ const groups = new Set(integration.configKeys.map((k) => data.schema[k]?.group).filter(Boolean));
727
+ const title = groups.size === 1 ? [...groups][0] : integration.name;
701
728
  cards.append(card(
702
- integration.name + (integration.optional ? ' (optional integration)' : ''),
729
+ title + (integration.optional ? ' (optional integration)' : ''),
703
730
  integration.configKeys, data, integration.configured,
704
731
  ));
705
732
  }
@@ -708,8 +735,83 @@ function render(data) {
708
735
  }
709
736
  }
710
737
 
738
+ function roCard(title, children) {
739
+ return el('div', { class: 'card' }, [
740
+ el('h2', { text: title }),
741
+ el('div', { class: 'body' }, children),
742
+ ]);
743
+ }
744
+
745
+ function renderManifest(m) {
746
+ const root = document.getElementById('manifest-cards');
747
+ root.replaceChildren();
748
+ const cards = [];
749
+
750
+ const about = [el('div', { class: 'desc', text: 'Version ' + m.version + (m.model ? ' · model: ' + m.model : '') })];
751
+ if (m.description) about.unshift(el('div', { text: m.description }));
752
+ cards.push(roCard('About', about));
753
+
754
+ if (m.instructions) {
755
+ cards.push(roCard('Instructions', [el('pre', { class: 'ro-pre', text: m.instructions })]));
756
+ }
757
+
758
+ const agentNames = Object.keys(m.agents);
759
+ if (agentNames.length > 0) {
760
+ cards.push(roCard('Agents', agentNames.map((name) => {
761
+ const a = m.agents[name];
762
+ const meta = [a.model && 'model: ' + a.model, a.tools && 'tools: ' + a.tools.join(', ')]
763
+ .filter(Boolean).join(' · ');
764
+ const body = meta ? [el('div', { class: 'desc', text: meta })] : [];
765
+ body.push(el('pre', { class: 'ro-pre', text: a.prompt }));
766
+ return el('details', { class: 'ro-item' }, [
767
+ el('summary', {}, [el('strong', { text: name }), document.createTextNode(' — ' + a.description)]),
768
+ ...body,
769
+ ]);
770
+ })));
771
+ }
772
+
773
+ if (m.skills.length > 0) {
774
+ cards.push(roCard('Skills', m.skills.map((s) => el('div', { class: 'ro-item' }, [
775
+ el('code', { text: s.dir + '/' }),
776
+ el('div', { class: 'desc', text: s.entries.length ? s.entries.join(', ') : '(directory not found or empty)' }),
777
+ ]))));
778
+ }
779
+
780
+ if (m.mcpServers.length > 0) {
781
+ cards.push(roCard('MCP servers', m.mcpServers.map((s) => el('div', { class: 'ro-item' }, [
782
+ el('strong', { text: s.name }),
783
+ document.createTextNode(' (' + s.type + (s.optional ? ', optional' : '') + ') '),
784
+ el('span', { class: 'chip ' + (s.configured ? 'on' : 'off'),
785
+ text: s.configured ? 'configured' : 'not configured' }),
786
+ el('div', { class: 'desc', text: s.type === 'stdio' ? s.command : s.url }),
787
+ ]))));
788
+ }
789
+
790
+ if (m.allowedTools.length > 0) {
791
+ cards.push(roCard('Allowed tools', m.allowedTools.map((t) =>
792
+ el('div', {}, [el('code', { text: t })]))));
793
+ }
794
+
795
+ if (m.jobs.length > 0) {
796
+ cards.push(roCard('Jobs', m.jobs.map((j) => el('div', { class: 'ro-item' }, [
797
+ el('strong', { text: j.name }),
798
+ el('code', { text: ' ' + j.schedule }),
799
+ ...(j.description ? [el('div', { class: 'desc', text: j.description })] : []),
800
+ ]))));
801
+ }
802
+
803
+ root.append(
804
+ el('h1', { text: 'App definition' }),
805
+ el('p', { class: 'subtitle', text: 'Declared by the app; read-only.' }),
806
+ ...cards,
807
+ );
808
+ }
809
+
711
810
  async function load() {
712
- const res = await fetch('/api/apps/' + APP + '/config');
811
+ const [res, manRes] = await Promise.all([
812
+ fetch('/api/apps/' + APP + '/config'),
813
+ fetch('/api/apps/' + APP + '/manifest'),
814
+ ]);
713
815
  if (!res.ok) {
714
816
  document.getElementById('banner').hidden = false;
715
817
  document.getElementById('banner').className = 'banner warn';
@@ -717,13 +819,14 @@ async function load() {
717
819
  return;
718
820
  }
719
821
  render(await res.json());
822
+ if (manRes.ok) renderManifest(await manRes.json());
720
823
  }
721
824
 
722
825
  document.getElementById('save').addEventListener('click', async () => {
723
826
  const body = {};
724
827
  for (const [key, { secret, input }] of state.dirty) {
725
828
  if (secret && input.value === '') continue; // untouched secret: keep as-is
726
- body[key] = parseInput(input.value);
829
+ body[key] = input.type === 'checkbox' ? input.checked : parseInput(input.value);
727
830
  }
728
831
  if (Object.keys(body).length === 0) return;
729
832
  const status = document.getElementById('status');
@@ -768,9 +871,15 @@ function escapeHtml(value) {
768
871
  * (omit conversationId to start a new conversation; the
769
872
  * id is echoed in the x-conversation-id response header)
770
873
  * GET /api/apps/:name/chat/conversations conversations, most recent first
874
+ * POST /api/apps/:name/chat/conversations { sessionId?, title? } -> create a conversation,
875
+ * optionally adopting an existing provider session (e.g. an
876
+ * app-run investigation) so chat continues where it left off
771
877
  * GET /api/apps/:name/chat/:conversationId/history past turns from the provider transcript
772
878
  * GET /api/apps/:name/jobs manifest-declared jobs with schedule/status
773
879
  * POST /api/apps/:name/jobs/:job/run trigger a job now (409 while already running)
880
+ * GET /api/apps/:name/manifest read-only app definition: instructions, agents,
881
+ * skills (with dir contents), allowed tools,
882
+ * MCP servers (with configured state), jobs, model
774
883
  * GET /api/apps/:name/config declared config schema + merged values
775
884
  * (secrets masked) + readiness/integrations
776
885
  * PUT /api/apps/:name/config merge keys into the per-app config layer
@@ -868,6 +977,30 @@ function createServer(store, provider) {
868
977
  }
869
978
  const app = new Hono();
870
979
  app.onError((err, c) => c.json({ error: String(err) }, 500));
980
+ const assetsDir = fileURLToPath(new URL("../assets", import.meta.url));
981
+ app.get("/assets/*", async (c) => {
982
+ let rest;
983
+ try {
984
+ rest = decodeURIComponent(c.req.path.slice(8));
985
+ } catch {
986
+ return c.json({ error: "Not found" }, 404);
987
+ }
988
+ const filePath = resolve(assetsDir, rest);
989
+ if (!filePath.startsWith(assetsDir + sep)) return c.json({ error: "Not found" }, 404);
990
+ try {
991
+ const body = await readFile(filePath);
992
+ const type = CONTENT_TYPES[extname(filePath)] ?? "application/octet-stream";
993
+ return new Response(new Uint8Array(body), {
994
+ status: 200,
995
+ headers: {
996
+ "content-type": type,
997
+ "cache-control": "public, max-age=300"
998
+ }
999
+ });
1000
+ } catch {
1001
+ return c.json({ error: "Not found" }, 404);
1002
+ }
1003
+ });
871
1004
  /** Readiness for the dashboard badge; undefined when the app fails to load. */
872
1005
  async function readiness(name) {
873
1006
  try {
@@ -883,15 +1016,38 @@ function createServer(store, provider) {
883
1016
  const rows = (await Promise.all(records.map(async (r) => {
884
1017
  const name = escapeHtml(r.name);
885
1018
  const status = await readiness(r.name);
886
- const badge = status && !status.ready ? ` <a href="/settings/${name}" style="color:#9c2121;font-weight:600">needs setup</a>` : "";
887
- return `<li><a href="/app/${name}/">${name}</a> <small>v${escapeHtml(r.version)} (${escapeHtml(r.source.type)})</small> <a href="/settings/${name}"><small>settings</small></a>${badge}</li>`;
1019
+ const badge = !status ? "<span class=\"badge err\">failed to load</span>" : status.ready ? "<span class=\"badge ok\">ready</span>" : `<a href="/settings/${name}"><span class="badge err">needs setup</span></a>`;
1020
+ return `<tr>
1021
+ <td><a href="/app/${name}/">${name}</a></td>
1022
+ <td class="muted">v${escapeHtml(r.version)}</td>
1023
+ <td class="muted">${escapeHtml(r.source.type)}</td>
1024
+ <td>${badge}</td>
1025
+ <td style="text-align:right"><a href="/settings/${name}">settings</a></td>
1026
+ </tr>`;
888
1027
  }))).join("\n");
1028
+ const body = records.length === 0 ? "<div class=\"empty\">No apps installed. Run <code>alashi install &lt;name&gt;</code>.</div>" : `<table>
1029
+ <thead><tr><th>App</th><th>Version</th><th>Source</th><th>Status</th><th></th></tr></thead>
1030
+ <tbody>
1031
+ ${rows}
1032
+ </tbody>
1033
+ </table>`;
889
1034
  return c.html(`<!doctype html>
890
1035
  <html lang="en">
891
- <head><meta charset="utf-8"><title>alashi</title></head>
1036
+ <head>
1037
+ <meta charset="utf-8">
1038
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1039
+ <title>alashi</title>
1040
+ <link rel="stylesheet" href="/assets/v1/system.css">
1041
+ </head>
892
1042
  <body>
893
- <h1>alashi</h1>
894
- ${records.length === 0 ? "<p>No apps installed. Run <code>alashi install &lt;name&gt;</code>.</p>" : `<ul>\n${rows}\n</ul>`}
1043
+ <header class="topbar"><strong>alashi</strong></header>
1044
+ <main class="narrow">
1045
+ <h1>Apps</h1>
1046
+ <p class="subtitle">Installed apps and their status.</p>
1047
+ <div class="panel">
1048
+ ${body}
1049
+ </div>
1050
+ </main>
895
1051
  </body>
896
1052
  </html>
897
1053
  `);
@@ -908,6 +1064,29 @@ ${records.length === 0 ? "<p>No apps installed. Run <code>alashi install &lt;nam
908
1064
  const { conversations } = await getChatState(name);
909
1065
  return c.json({ conversations: [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) });
910
1066
  });
1067
+ app.post("/api/apps/:name/chat/conversations", async (c) => {
1068
+ const name = c.req.param("name");
1069
+ if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
1070
+ let sessionId;
1071
+ let title;
1072
+ try {
1073
+ ({sessionId, title} = await c.req.json());
1074
+ } catch {
1075
+ return c.json({ error: "Body must be JSON" }, 400);
1076
+ }
1077
+ if (sessionId !== void 0 && (typeof sessionId !== "string" || sessionId.length === 0)) return c.json({ error: "\"sessionId\" must be a non-empty string" }, 400);
1078
+ if (title !== void 0 && typeof title !== "string") return c.json({ error: "\"title\" must be a string" }, 400);
1079
+ const state = await getChatState(name);
1080
+ const convo = {
1081
+ id: randomUUID(),
1082
+ title: (title ?? "").slice(0, 80) || "New conversation",
1083
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1084
+ ...sessionId !== void 0 && { sessionId }
1085
+ };
1086
+ state.conversations.push(convo);
1087
+ await store.writeChatState(name, state);
1088
+ return c.json({ conversation: convo }, 201);
1089
+ });
911
1090
  app.get("/api/apps/:name/chat/:conversationId/history", async (c) => {
912
1091
  const name = c.req.param("name");
913
1092
  if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
@@ -927,6 +1106,49 @@ ${records.length === 0 ? "<p>No apps installed. Run <code>alashi install &lt;nam
927
1106
  session.then((s) => s.close()).catch(() => void 0);
928
1107
  }
929
1108
  }
1109
+ app.get("/api/apps/:name/manifest", async (c) => {
1110
+ const name = c.req.param("name");
1111
+ const running = await getApp(name);
1112
+ if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
1113
+ const { manifest } = running;
1114
+ const { merged, status } = await resolveAppConfig(store, manifest);
1115
+ const repoDir = store.appRepoDir(name);
1116
+ const skills = await Promise.all(manifest.skills.map(async (dir) => {
1117
+ let entries = [];
1118
+ try {
1119
+ entries = (await readdir(resolve(repoDir, dir))).sort();
1120
+ } catch {}
1121
+ return {
1122
+ dir,
1123
+ entries
1124
+ };
1125
+ }));
1126
+ const configured = new Map(status.integrations.map((i) => [i.name, i.configured]));
1127
+ const mcpServers = Object.entries(manifest.mcpServers).map(([server, decl]) => ({
1128
+ name: server,
1129
+ ..."command" in decl ? {
1130
+ type: "stdio",
1131
+ command: [decl.command, ...decl.args ?? []].join(" ")
1132
+ } : {
1133
+ type: decl.type,
1134
+ url: decl.url
1135
+ },
1136
+ optional: decl.optional === true,
1137
+ configured: configured.get(server) ?? true
1138
+ }));
1139
+ return c.json({
1140
+ name: manifest.name,
1141
+ version: manifest.version,
1142
+ ...manifest.description !== void 0 && { description: manifest.description },
1143
+ ...manifest.instructions !== void 0 && { instructions: manifest.instructions },
1144
+ ...typeof merged.model === "string" && { model: merged.model },
1145
+ agents: manifest.agents,
1146
+ skills,
1147
+ allowedTools: manifest.permissions.allowedTools,
1148
+ mcpServers,
1149
+ jobs: manifest.jobs
1150
+ });
1151
+ });
930
1152
  app.get("/api/apps/:name/config", async (c) => {
931
1153
  const name = c.req.param("name");
932
1154
  const running = await getApp(name);
@@ -1198,4 +1420,4 @@ var Store = class {
1198
1420
  //#endregion
1199
1421
  export { checkSdkCompatibility as _, installApp as a, addRegistry as c, removeRegistry as d, resolveApp as f, loadApp as g, updateSource as h, resolveAppConfig as i, readCachedIndex as l, parseSourceArg as m, createServer as n, removeApp as o, fetchSource as p, buildAppContext as r, updateApp as s, Store as t, refreshRegistry as u };
1200
1422
 
1201
- //# sourceMappingURL=store-CwgDCAAx.mjs.map
1423
+ //# sourceMappingURL=store-Ckx0oLB6.mjs.map