@skillcdn/console 0.1.6 → 0.1.8

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.
Files changed (97) hide show
  1. package/README.md +50 -2
  2. package/SKILLCDN.md +35 -0
  3. package/dist/cli/cli.d.ts +3 -0
  4. package/dist/cli/cli.d.ts.map +1 -1
  5. package/dist/cli/cli.js +61 -0
  6. package/dist/cli/cli.js.map +1 -1
  7. package/dist/cli/help.d.ts.map +1 -1
  8. package/dist/cli/help.js +5 -0
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/main.js +2 -0
  11. package/dist/cli/main.js.map +1 -1
  12. package/dist/cli/serve.d.ts +37 -0
  13. package/dist/cli/serve.d.ts.map +1 -0
  14. package/dist/cli/serve.js +218 -0
  15. package/dist/cli/serve.js.map +1 -0
  16. package/dist/console.d.ts +6 -0
  17. package/dist/console.d.ts.map +1 -1
  18. package/dist/console.js +78 -13
  19. package/dist/console.js.map +1 -1
  20. package/dist/data.d.ts.map +1 -1
  21. package/dist/data.js +5 -1
  22. package/dist/data.js.map +1 -1
  23. package/dist/i18n/en.d.ts +4 -0
  24. package/dist/i18n/en.d.ts.map +1 -1
  25. package/dist/i18n/en.js +4 -0
  26. package/dist/i18n/en.js.map +1 -1
  27. package/dist/i18n/ko.d.ts.map +1 -1
  28. package/dist/i18n/ko.js +4 -0
  29. package/dist/i18n/ko.js.map +1 -1
  30. package/dist/schemas.d.ts +1 -0
  31. package/dist/schemas.d.ts.map +1 -1
  32. package/dist/schemas.js +5 -0
  33. package/dist/schemas.js.map +1 -1
  34. package/dist/web.d.ts +16 -0
  35. package/dist/web.d.ts.map +1 -0
  36. package/dist/web.js +149 -0
  37. package/dist/web.js.map +1 -0
  38. package/docs/specs/cli.md +92 -0
  39. package/docs/specs/rest.md +221 -0
  40. package/package.json +14 -3
  41. package/skills/building-a-console/SKILL.md +102 -0
  42. package/skills/building-a-console/references/the-default-console.md +140 -0
  43. package/skills/deploying-a-console/SKILL.md +130 -0
  44. package/skills/deploying-a-console/assets/deployer-policy.json +118 -0
  45. package/skills/deploying-a-console/assets/secrets.example.json +11 -0
  46. package/skills/deploying-a-console/references/access.md +78 -0
  47. package/skills/deploying-a-console/references/resources.md +516 -0
  48. package/skills/deploying-a-console/references/sizing.md +90 -0
  49. package/skills/deploying-a-console/scripts/import-aws-key.mjs +35 -0
  50. package/skills/deploying-a-console/scripts/store-secrets.mjs +114 -0
  51. package/skills/working-the-board/SKILL.md +86 -0
  52. package/src/api.ts +235 -0
  53. package/src/cli/cli.ts +1114 -0
  54. package/src/cli/credentials.ts +68 -0
  55. package/src/cli/directory.ts +59 -0
  56. package/src/cli/format.ts +284 -0
  57. package/src/cli/help.ts +326 -0
  58. package/src/cli/main.ts +39 -0
  59. package/src/cli/serve.ts +276 -0
  60. package/src/cli/terminal.ts +28 -0
  61. package/src/client.ts +533 -0
  62. package/src/components/board.tsx +112 -0
  63. package/src/components/connect.tsx +126 -0
  64. package/src/components/decision-form.tsx +136 -0
  65. package/src/components/decision-list.tsx +278 -0
  66. package/src/components/documents.tsx +569 -0
  67. package/src/components/event-feed.tsx +129 -0
  68. package/src/components/markdown.tsx +72 -0
  69. package/src/components/people.tsx +59 -0
  70. package/src/components/projects.tsx +364 -0
  71. package/src/components/runs.tsx +175 -0
  72. package/src/components/shell.tsx +284 -0
  73. package/src/components/sign-in.tsx +61 -0
  74. package/src/components/skills.tsx +78 -0
  75. package/src/components/task-form.tsx +229 -0
  76. package/src/components/task-view.tsx +272 -0
  77. package/src/components/tokens.tsx +223 -0
  78. package/src/components/ui.tsx +219 -0
  79. package/src/connect-page.tsx +148 -0
  80. package/src/connect.ts +27 -0
  81. package/src/console.tsx +1166 -0
  82. package/src/data.ts +493 -0
  83. package/src/docs-page.tsx +334 -0
  84. package/src/documents.ts +76 -0
  85. package/src/i18n/en.ts +537 -0
  86. package/src/i18n/index.ts +76 -0
  87. package/src/i18n/ko.ts +511 -0
  88. package/src/index.ts +142 -0
  89. package/src/limits.ts +61 -0
  90. package/src/router.ts +295 -0
  91. package/src/routes.ts +106 -0
  92. package/src/schemas.ts +776 -0
  93. package/src/styles/console.css +2432 -0
  94. package/src/text.ts +33 -0
  95. package/src/use-action.ts +37 -0
  96. package/src/vocabulary.ts +109 -0
  97. package/src/web.ts +170 -0
@@ -0,0 +1,219 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
2
+ import type { PersonRole, RestPerson, TaskPriority, TaskState } from "../api.js";
3
+ import { useLanguage, useWords } from "../i18n/index.js";
4
+
5
+ // Small building blocks. Each one is a class in styles/console.css and nothing more. Classes
6
+ // are prefixed `sc-` so that a custom console's own styles never collide with them. The words
7
+ // come from the language the page is in (ADR-0012).
8
+
9
+ export function cx(...names: (string | false | undefined)[]): string {
10
+ return names.filter(Boolean).join(" ");
11
+ }
12
+
13
+ export function Button(
14
+ props: ButtonHTMLAttributes<HTMLButtonElement> & {
15
+ readonly variant?: "primary" | "secondary" | "ghost" | "danger";
16
+ readonly size?: "md" | "sm";
17
+ },
18
+ ) {
19
+ const { variant = "secondary", size = "md", className, type = "button", ...rest } = props;
20
+ return (
21
+ <button
22
+ {...rest}
23
+ type={type}
24
+ className={cx(
25
+ "sc-button",
26
+ `sc-button-${variant}`,
27
+ size === "sm" && "sc-button-sm",
28
+ className,
29
+ )}
30
+ />
31
+ );
32
+ }
33
+
34
+ export function Badge(props: {
35
+ readonly tone?: "neutral" | "accent" | "point" | "success" | "warning" | "danger";
36
+ readonly title?: string;
37
+ readonly children: ReactNode;
38
+ }) {
39
+ return (
40
+ <span className={cx("sc-badge", `sc-badge-${props.tone ?? "neutral"}`)} title={props.title}>
41
+ {props.children}
42
+ </span>
43
+ );
44
+ }
45
+
46
+ export function Callout(props: {
47
+ readonly tone?: "info" | "warning" | "danger";
48
+ readonly title?: string;
49
+ readonly children?: ReactNode;
50
+ readonly action?: ReactNode;
51
+ }) {
52
+ const tone = props.tone ?? "info";
53
+ return (
54
+ <div
55
+ className={cx("sc-callout", `sc-callout-${tone}`)}
56
+ role={tone === "danger" ? "alert" : "status"}
57
+ >
58
+ <div>
59
+ {props.title !== undefined && <p className="sc-callout-title">{props.title}</p>}
60
+ {props.children !== undefined && <div className="sc-callout-body">{props.children}</div>}
61
+ </div>
62
+ {props.action}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ /**
68
+ * A person's picture as the git host serves it: loaded lazily, without a referrer, and gone as
69
+ * a whole when there is none or it cannot be loaded, leaving the initial of the login instead.
70
+ */
71
+ export function Avatar(props: { readonly person: RestPerson; readonly size?: "sm" | "md" | "lg" }) {
72
+ const size = props.size ?? "md";
73
+ const initial = props.person.login.slice(0, 1).toUpperCase();
74
+ if (props.person.avatar === null) {
75
+ return (
76
+ <span
77
+ className={cx("sc-avatar", "sc-avatar-initial", `sc-avatar-${size}`)}
78
+ aria-hidden="true"
79
+ >
80
+ {initial}
81
+ </span>
82
+ );
83
+ }
84
+ return (
85
+ <img
86
+ className={cx("sc-avatar", `sc-avatar-${size}`)}
87
+ src={props.person.avatar}
88
+ alt=""
89
+ loading="lazy"
90
+ decoding="async"
91
+ referrerPolicy="no-referrer"
92
+ />
93
+ );
94
+ }
95
+
96
+ /** A person, as a picture and a name, wherever one is named. */
97
+ export function PersonChip(props: { readonly person: RestPerson; readonly size?: "sm" | "md" }) {
98
+ return (
99
+ <span className="sc-person" title={props.person.name ?? props.person.login}>
100
+ <Avatar person={props.person} size={props.size ?? "sm"} />
101
+ <span className="sc-person-login">{props.person.login}</span>
102
+ </span>
103
+ );
104
+ }
105
+
106
+ /** The words of the vocabulary as people read them, in English: the default pack's. */
107
+ export const STATE_LABELS: Readonly<Record<TaskState, string>> = {
108
+ idea: "Idea",
109
+ ready: "Ready",
110
+ in_progress: "In progress",
111
+ in_review: "In review",
112
+ done: "Done",
113
+ dropped: "Dropped",
114
+ };
115
+
116
+ export const PRIORITY_LABELS: Readonly<Record<TaskPriority, string>> = {
117
+ low: "Low",
118
+ normal: "Normal",
119
+ high: "High",
120
+ urgent: "Urgent",
121
+ };
122
+
123
+ export const ROLE_LABELS: Readonly<Record<PersonRole, string>> = {
124
+ admin: "Administrator",
125
+ member: "Member",
126
+ };
127
+
128
+ export function RoleBadge(props: { readonly role: PersonRole }) {
129
+ const words = useWords();
130
+ return (
131
+ <Badge tone={props.role === "admin" ? "point" : "neutral"}>
132
+ {words.vocabulary.role[props.role]}
133
+ </Badge>
134
+ );
135
+ }
136
+
137
+ export function StateBadge(props: { readonly state: TaskState }) {
138
+ const words = useWords();
139
+ const tone =
140
+ props.state === "done"
141
+ ? "success"
142
+ : props.state === "dropped"
143
+ ? "neutral"
144
+ : props.state === "in_progress" || props.state === "in_review"
145
+ ? "accent"
146
+ : "neutral";
147
+ return <Badge tone={tone}>{words.vocabulary.state[props.state]}</Badge>;
148
+ }
149
+
150
+ export function PriorityBadge(props: { readonly priority: TaskPriority }) {
151
+ const words = useWords();
152
+ if (props.priority === "normal") {
153
+ return null;
154
+ }
155
+ const tone =
156
+ props.priority === "urgent" ? "danger" : props.priority === "high" ? "point" : "neutral";
157
+ return <Badge tone={tone}>{words.vocabulary.priority[props.priority]}</Badge>;
158
+ }
159
+
160
+ export function Spinner(props: { readonly label: string }) {
161
+ return (
162
+ <span className="sc-spinner" role="status">
163
+ <span className="sc-spinner-ring" aria-hidden="true" />
164
+ <span className="sc-visually-hidden">{props.label}</span>
165
+ </span>
166
+ );
167
+ }
168
+
169
+ /** Where there is nothing yet, said plainly, with the way to the first thing. */
170
+ export function EmptyState(props: {
171
+ readonly title: string;
172
+ readonly body?: string;
173
+ readonly action?: ReactNode;
174
+ }) {
175
+ return (
176
+ <div className="sc-empty">
177
+ <p className="sc-empty-title">{props.title}</p>
178
+ {props.body !== undefined && <p className="sc-empty-body">{props.body}</p>}
179
+ {props.action !== undefined && <div className="sc-empty-action">{props.action}</div>}
180
+ </div>
181
+ );
182
+ }
183
+
184
+ /** An instant as people read one in a language: the date, and the time of day. */
185
+ export function formatInstant(iso: string, language?: string): string {
186
+ const date = new Date(iso);
187
+ if (Number.isNaN(date.getTime())) {
188
+ return iso;
189
+ }
190
+ return date.toLocaleString(language, {
191
+ year: "numeric",
192
+ month: "short",
193
+ day: "numeric",
194
+ hour: "2-digit",
195
+ minute: "2-digit",
196
+ });
197
+ }
198
+
199
+ /** A size in bytes as people read one: `12 B`, `3.4 KB`, `120 MB`. */
200
+ export function formatBytes(size: number): string {
201
+ if (size < 1024) {
202
+ return `${size} B`;
203
+ }
204
+ const kb = size / 1024;
205
+ if (kb < 1024) {
206
+ return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`;
207
+ }
208
+ const mb = kb / 1024;
209
+ return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`;
210
+ }
211
+
212
+ export function Time(props: { readonly iso: string }) {
213
+ const { tag } = useLanguage();
214
+ return (
215
+ <time className="sc-time" dateTime={props.iso}>
216
+ {formatInstant(props.iso, tag)}
217
+ </time>
218
+ );
219
+ }
@@ -0,0 +1,148 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ ApiError,
4
+ normalizeConnectCode,
5
+ type RestConnectRequest,
6
+ type RestTokenInput,
7
+ } from "./api.js";
8
+ import { ConnectApproval, ConnectCodeForm } from "./components/connect.js";
9
+ import { Callout, Spinner } from "./components/ui.js";
10
+ import type { ConsoleData } from "./data.js";
11
+ import { useWords } from "./i18n/index.js";
12
+ import { connectHref, type Navigation, PATHS } from "./router.js";
13
+ import { useAction } from "./use-action.js";
14
+
15
+ // The page where a person connects their agent (ADR-0011): the code typed when the address
16
+ // does not carry one, what asks under it, the approval, and what came of it. Part of the
17
+ // default composition; a custom console composes the same components its own way.
18
+
19
+ export interface ConnectPageProps {
20
+ /** The code in the address, as typed or followed, or nothing. */
21
+ readonly code: string | undefined;
22
+ readonly data: ConsoleData;
23
+ readonly navigation: Navigation;
24
+ }
25
+
26
+ export function ConnectPage(props: ConnectPageProps) {
27
+ const words = useWords().connect;
28
+ const { data, navigation } = props;
29
+ const code = props.code === undefined ? undefined : normalizeConnectCode(props.code);
30
+ const [request, setRequest] = useState<RestConnectRequest | undefined>(undefined);
31
+ const [problem, setProblem] = useState<string | undefined>(undefined);
32
+ const [outcome, setOutcome] = useState<"approved" | "denied" | undefined>(undefined);
33
+ const { busy, error, act } = useAction();
34
+ const { connectRequest } = data.actions;
35
+
36
+ // What asks under the code, read once the page is on one.
37
+ useEffect(() => {
38
+ setRequest(undefined);
39
+ setProblem(undefined);
40
+ setOutcome(undefined);
41
+ if (code === undefined) {
42
+ return;
43
+ }
44
+ const controller = new AbortController();
45
+ connectRequest(code, controller.signal)
46
+ .then((found) => {
47
+ if (!controller.signal.aborted) {
48
+ setRequest(found);
49
+ }
50
+ })
51
+ .catch((failure: unknown) => {
52
+ if (controller.signal.aborted) {
53
+ return;
54
+ }
55
+ setProblem(
56
+ failure instanceof ApiError && failure.code === "connect.not_found"
57
+ ? words.noSuchCode
58
+ : failure instanceof ApiError
59
+ ? failure.message
60
+ : words.couldNotAsk,
61
+ );
62
+ });
63
+ return () => controller.abort();
64
+ }, [code, connectRequest, words]);
65
+
66
+ const head = (
67
+ <div className="sc-page-head">
68
+ <h1 className="sc-page-title">{words.title}</h1>
69
+ </div>
70
+ );
71
+ const ask = (
72
+ <>
73
+ <p className="sc-lead">{words.lead}</p>
74
+ <section className="sc-panel" aria-label={words.aria}>
75
+ <ConnectCodeForm busy={busy} onSubmit={(typed) => navigation.go(connectHref(typed))} />
76
+ </section>
77
+ </>
78
+ );
79
+
80
+ if (outcome === "approved" && request !== undefined) {
81
+ return (
82
+ <>
83
+ {head}
84
+ <Callout tone="info" title={words.approved.title(request.agent)}>
85
+ <p>
86
+ {words.approved.body} <a href={PATHS.agents}>{words.approved.agentsPage}</a>
87
+ </p>
88
+ </Callout>
89
+ </>
90
+ );
91
+ }
92
+ if (outcome === "denied") {
93
+ return (
94
+ <>
95
+ {head}
96
+ <Callout tone="info" title={words.denied.title}>
97
+ <p>{words.denied.body}</p>
98
+ </Callout>
99
+ {ask}
100
+ </>
101
+ );
102
+ }
103
+ if (code === undefined || problem !== undefined) {
104
+ return (
105
+ <>
106
+ {head}
107
+ {props.code !== undefined && code === undefined && (
108
+ <Callout tone="warning">{words.notACode}</Callout>
109
+ )}
110
+ {problem !== undefined && <Callout tone="warning">{problem}</Callout>}
111
+ {ask}
112
+ </>
113
+ );
114
+ }
115
+ if (request === undefined) {
116
+ return (
117
+ <>
118
+ {head}
119
+ <div className="sc-loading">
120
+ <Spinner label={words.lookingUp} />
121
+ </div>
122
+ </>
123
+ );
124
+ }
125
+ return (
126
+ <>
127
+ {head}
128
+ <ConnectApproval
129
+ request={request}
130
+ daysAtMost={data.me?.workspace.tokenDaysAtMost}
131
+ busy={busy}
132
+ error={error}
133
+ onApprove={(input: RestTokenInput) =>
134
+ void act(async () => {
135
+ await data.actions.approveConnection(code, input);
136
+ setOutcome("approved");
137
+ })
138
+ }
139
+ onDeny={() =>
140
+ void act(async () => {
141
+ await data.actions.denyConnection(code);
142
+ setOutcome("denied");
143
+ })
144
+ }
145
+ />
146
+ </>
147
+ );
148
+ }
package/src/connect.ts ADDED
@@ -0,0 +1,27 @@
1
+ // A short code a person reads off their agent's screen and types, or follows, on the console's
2
+ // own pages to connect the agent (ADR-0011): eight characters from an alphabet without
3
+ // look-alikes, in two groups of four. The server makes codes; everything else reads them.
4
+
5
+ /** The characters a code is made of: no `0`, `1`, `I` or `O`, which read alike. */
6
+ export const CONNECT_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
7
+ export const CONNECT_CODE_LENGTH = 8;
8
+
9
+ const CODE = /^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/;
10
+
11
+ /** Whether the text is a code as it is written: upper case, a hyphen in the middle. */
12
+ export function isConnectCode(text: string): boolean {
13
+ return CODE.test(text);
14
+ }
15
+
16
+ /**
17
+ * The code as it is written, from what a person typed: any case, with or without the hyphen,
18
+ * spaces around or between; or nothing for text that is not a code.
19
+ */
20
+ export function normalizeConnectCode(text: string): string | undefined {
21
+ const letters = text.toUpperCase().replaceAll(/[^A-Z0-9]/g, "");
22
+ if (letters.length !== CONNECT_CODE_LENGTH) {
23
+ return undefined;
24
+ }
25
+ const code = `${letters.slice(0, 4)}-${letters.slice(4)}`;
26
+ return CODE.test(code) ? code : undefined;
27
+ }