wheems 0.0.2 → 0.0.3

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/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  Wheems follows [semantic versioning](https://semver.org). While it's 0.0.x, any release may
4
4
  change anything, including commands and config formats, without a migration path.
5
5
 
6
+ ## 0.0.3
7
+
8
+ A new look for the cloud in the terminal, and clearer help when a Slack token is wrong. Still an
9
+ early preview: not ready for real use. The first release published from GitHub.
10
+
11
+ **Changed**
12
+
13
+ - The cloud on the welcome screen and in `wheems setup` is now the mascot's outline, with its eye
14
+ and smile, instead of a solid shape whose face got lost. It's drawn in the terminal's text colour
15
+ (white on a dark terminal, near-black on a light one) rather than blue. It's left out when the
16
+ output isn't a terminal (piped or saved to a file) and when the terminal is narrower than 53
17
+ columns, so only the name, tagline and status are shown.
18
+ - The cloud winks once when it appears, in an interactive terminal. Set `WHEEMS_NO_ANIMATION` to
19
+ stop it; it never winks in CI, in a dumb terminal, or when the output isn't a terminal.
20
+
21
+ **Fixed**
22
+
23
+ - When a pasted Slack token isn't right, setup says why instead of only "The bot token starts with
24
+ xoxb-": the app-level token pasted for the bot token (or the other way round), a user or refresh
25
+ token, two pastes run together, something in front of the token, or a space in it, each with
26
+ where in Slack to find the right one. It names tokens only by their standard prefix.
27
+
6
28
  ## 0.0.2
7
29
 
8
30
  Fixes from the first preview, a welcome screen, and checks that catch common set-up problems.
package/README.md CHANGED
@@ -665,6 +665,7 @@ skills:
665
665
  | `WHEEMS_SECRETS` | The secrets file (default `~/.config/wheems/secrets.json`, or under `$XDG_CONFIG_HOME`) |
666
666
  | `WHEEMS_WORKSPACE` | The workspace `wheems kb search` searches. Wheems sets it in every agent session |
667
667
  | `WHEEMS_CONVERSATION` | The conversation an agent's commands belong to. Wheems sets it in every agent session |
668
+ | `WHEEMS_NO_ANIMATION` | Set to anything to stop the cloud winking on the welcome screen and in `wheems setup` |
668
669
 
669
670
  ### Config the schemas accept but nothing uses yet
670
671
 
@@ -798,6 +799,23 @@ also run it against a fake `hc`, which is what CI uses. Set `WHEEMS_TEST_OLLAMA_
798
799
  | `examples/install/` | A complete example install |
799
800
  | `docs/` | Design documents |
800
801
 
802
+ ### Releasing
803
+
804
+ GitHub publishes each release. To release, merge a pull request to `main` that:
805
+
806
+ 1. changes `version` in `package.json` (and `package-lock.json`);
807
+ 2. adds a `## <version>` section to `CHANGELOG.md`.
808
+
809
+ The [release workflow](.github/workflows/release.yml) then runs the full check on Linux and
810
+ macOS and publishes to npm. It then tags the commit `v<version>` and creates a GitHub release
811
+ with that changelog section as its notes. A push to `main` whose version is already published and
812
+ tagged does nothing.
813
+
814
+ npm trusts the workflow through
815
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm token is stored
816
+ anywhere. The package's settings on npmjs.com name this repository and `release.yml` as its
817
+ trusted publisher.
818
+
801
819
  ## More documentation
802
820
 
803
821
  The documents in [`docs/`](docs/) go deeper, and some describe the design of milestones not built
package/dist/cli/main.js CHANGED
@@ -14,6 +14,7 @@ process.exitCode = await run(process.argv.slice(2), {
14
14
  err: (text) => process.stderr.write(text),
15
15
  env: process.env,
16
16
  color,
17
+ columns: process.stdout.isTTY ? process.stdout.columns : undefined,
17
18
  ...defaultIo(),
18
19
  });
19
20
  //# sourceMappingURL=main.js.map
@@ -8,6 +8,8 @@ export interface Io {
8
8
  err: (text: string) => void;
9
9
  env: NodeJS.ProcessEnv;
10
10
  color: boolean;
11
+ /** The terminal's width, or undefined when the output isn't a terminal. */
12
+ columns?: number | undefined;
11
13
  registry: () => DriverRegistry;
12
14
  stdin: () => Promise<string>;
13
15
  }
@@ -17,7 +17,7 @@ import { registerReply } from './commands/reply.js';
17
17
  import { registerService } from './commands/service.js';
18
18
  import { ensureInstall, setup } from './commands/setup.js';
19
19
  import { clackUi } from './clack-ui.js';
20
- import { renderWelcome, welcomeStatus } from './welcome.js';
20
+ import { canWink, renderWelcomeHead, renderWelcomeTail, SHOW_CURSOR, showCloud, welcomeStatus, wink, } from './welcome.js';
21
21
  import { installAndWait, serviceAnswers } from './commands/service.js';
22
22
  import { runCommand, runInteractive } from '../util/process.js';
23
23
  import { SetupCancelled } from '../contracts/setup.js';
@@ -99,7 +99,13 @@ export function createProgram(io) {
99
99
  const where = paths(home, io.env);
100
100
  const registry = io.registry();
101
101
  const ui = clackUi();
102
- io.out(renderWelcome({ version: packageVersion(), status: [], color: io.color, hints: false }));
102
+ io.out(renderWelcomeHead({
103
+ version: packageVersion(),
104
+ color: io.color,
105
+ cloud: showCloud(io.columns),
106
+ }));
107
+ if (canWink(io.columns, io.env))
108
+ await winkOnce(io);
103
109
  ui.intro(' Wheems setup ');
104
110
  try {
105
111
  await setup({
@@ -213,18 +219,43 @@ export function createProgram(io) {
213
219
  function shellPath(path) {
214
220
  return /^[\w./-]+$/.test(path) ? path : `'${path.replaceAll("'", `'\\''`)}'`;
215
221
  }
222
+ /**
223
+ * The cloud winks once. Ctrl+C meanwhile still gives the terminal its cursor back before
224
+ * stopping, as it would have done without the wink.
225
+ */
226
+ async function winkOnce(io) {
227
+ const interrupted = () => {
228
+ io.out(SHOW_CURSOR);
229
+ process.exit(130);
230
+ };
231
+ process.once('SIGINT', interrupted);
232
+ try {
233
+ await wink({ version: packageVersion(), color: io.color, write: io.out });
234
+ }
235
+ finally {
236
+ process.off('SIGINT', interrupted);
237
+ }
238
+ }
216
239
  /** Runs the CLI and returns the exit code. With no command, it shows the welcome screen. */
217
240
  export async function run(argv, io) {
218
241
  if (argv.length === 0) {
219
242
  const home = resolve(defaultHome(io.env));
220
243
  // The welcome never fails: a problem reading the install becomes one of its lines.
221
- const status = await welcomeStatus(home, io.env).catch((error) => [
244
+ // The status is read while the cloud is drawn and winks.
245
+ const reading = welcomeStatus(home, io.env).catch((error) => [
222
246
  {
223
247
  mark: '!',
224
248
  text: `Couldn't read the install at ${home}: ${error instanceof Error ? error.message : String(error)}. Run wheems doctor.`,
225
249
  },
226
250
  ]);
227
- io.out(renderWelcome({ version: packageVersion(), status, color: io.color }));
251
+ io.out(renderWelcomeHead({
252
+ version: packageVersion(),
253
+ color: io.color,
254
+ cloud: showCloud(io.columns),
255
+ }));
256
+ if (canWink(io.columns, io.env))
257
+ await winkOnce(io);
258
+ io.out(renderWelcomeTail({ status: await reading, color: io.color }));
228
259
  return 0;
229
260
  }
230
261
  const program = createProgram(io);
@@ -1,25 +1,68 @@
1
1
  /**
2
- * The cloud, from the brand kit's terminal mark (Unicode half blocks, drawn from the mascot's
3
- * silhouette). Its eye and smile are the gaps the half blocks leave.
2
+ * The cloud: the mascot's outline (the brand kit's mascot.svg), traced in Unicode half blocks,
3
+ * with its eye and smile placed by hand so they read at this size. Half blocks are in every
4
+ * monospace font and most terminals draw them as exact rectangles, so it looks the same
5
+ * everywhere; an outline also reads on light and dark backgrounds alike. It has no colour of
6
+ * its own: it's drawn in the terminal's text colour, like the brand's ink app icon, so it's white
7
+ * on a dark terminal and near-black on a light one.
4
8
  */
5
9
  export declare const CLOUD: string[];
6
10
  /** The brand line under the name. */
7
11
  export declare const TAGLINE = "Your crew, ready for work.";
12
+ /** The columns the cloud and the words beside it need. */
13
+ export declare const WELCOME_WIDTH: number;
14
+ /**
15
+ * Whether to draw the cloud: only in a terminal wide enough for it and the words beside it.
16
+ * Piped into a file or another program, or read out by a screen reader, it would only be
17
+ * noise. `columns` is undefined when the output isn't a terminal.
18
+ */
19
+ export declare function showCloud(columns: number | undefined): boolean;
8
20
  export interface WelcomeStatus {
9
21
  mark: '✓' | '!' | '-';
10
22
  text: string;
11
23
  }
12
- /** The welcome screen: the cloud with the name and tagline beside it, then the install's status. */
13
- export declare function renderWelcome(options: {
24
+ interface WelcomeOptions {
14
25
  version: string;
15
26
  status: WelcomeStatus[];
16
27
  color: boolean;
17
28
  /** Ends with the commands to try next. On by default. */
18
29
  hints?: boolean;
19
- }): string;
30
+ /** Draws the cloud. On by default; see showCloud for when to leave it out. */
31
+ cloud?: boolean;
32
+ }
33
+ /** The welcome screen: the cloud with the name and tagline beside it, then the install's status. */
34
+ export declare function renderWelcome(options: WelcomeOptions): string;
35
+ /** The top of the welcome screen: the cloud and the words beside it, or just the words. */
36
+ export declare function renderWelcomeHead(options: Omit<WelcomeOptions, 'status'>): string;
37
+ /** The rest of the welcome screen: the install's status and the commands to try. */
38
+ export declare function renderWelcomeTail(options: Omit<WelcomeOptions, 'version' | 'cloud'>): string;
39
+ /**
40
+ * Whether the cloud may wink: only when it's drawn, so in a wide enough terminal, and not in
41
+ * CI, a dumb terminal, or for someone who has set WHEEMS_NO_ANIMATION.
42
+ */
43
+ export declare function canWink(columns: number | undefined, env: NodeJS.ProcessEnv): boolean;
44
+ /** How long the eye stays open before the wink, and how long it stays shut. */
45
+ export declare const WINK_TIMING: {
46
+ readonly before: 400;
47
+ readonly shut: 150;
48
+ };
49
+ /**
50
+ * The cloud winks once: run straight after renderWelcomeHead is written, with the cursor on the
51
+ * line under the cloud. It redraws only the eye's row, hides the cursor meanwhile and always
52
+ * shows it again.
53
+ */
54
+ export declare function wink(options: {
55
+ version: string;
56
+ color: boolean;
57
+ write: (text: string) => void;
58
+ sleep?: (ms: number) => Promise<void>;
59
+ }): Promise<void>;
60
+ export declare const HIDE_CURSOR = "\u001B[?25l";
61
+ export declare const SHOW_CURSOR = "\u001B[?25h";
20
62
  /**
21
63
  * What the welcome screen says about this install: its workspaces and agents, the service,
22
64
  * search, and anything waiting for review. Quick: it reads files and pings the service, and
23
65
  * never starts a session.
24
66
  */
25
67
  export declare function welcomeStatus(home: string, env: NodeJS.ProcessEnv, ping?: (socket: string) => Promise<boolean>): Promise<WelcomeStatus[]>;
68
+ export {};
@@ -6,51 +6,70 @@ import { listAboutYou } from '../review/review.js';
6
6
  import { request } from '../service/control.js';
7
7
  import { listProposals } from '../skills/proposals.js';
8
8
  /**
9
- * The cloud, from the brand kit's terminal mark (Unicode half blocks, drawn from the mascot's
10
- * silhouette). Its eye and smile are the gaps the half blocks leave.
9
+ * The cloud: the mascot's outline (the brand kit's mascot.svg), traced in Unicode half blocks,
10
+ * with its eye and smile placed by hand so they read at this size. Half blocks are in every
11
+ * monospace font and most terminals draw them as exact rectangles, so it looks the same
12
+ * everywhere; an outline also reads on light and dark backgrounds alike. It has no colour of
13
+ * its own: it's drawn in the terminal's text colour, like the brand's ink app icon, so it's white
14
+ * on a dark terminal and near-black on a light one.
11
15
  */
12
16
  // Every character is one UTF-16 unit, so .length is the width in columns.
13
17
  export const CLOUD = [
14
- ' ▄▄██▄▄▄',
15
- ' ██████████',
16
- ' ▄██▄████████████',
17
- ' █████████████████',
18
- ' ▄█████████████████',
19
- ' ▄████▀█████████████▄▄',
20
- '▄██████████████████████',
21
- '▀████▄▀▄████████████████',
22
- ' ██████████████████████',
23
- ' ▀▀▀███████████████▀▀',
18
+ ' ▄█████▄▄',
19
+ ' ▄█▀ ▀▀█▄',
20
+ ' ▄█████▀ ▀█▄',
21
+ ' ██ ██',
22
+ ' ▄██ █▀',
23
+ ' ██▀ ██ ▀█▄▄',
24
+ '██ ▀█▄',
25
+ '██ ▀▄▄▀ ██',
26
+ ' ██▄ ▄█▀',
27
+ ' ▀▀█████████████████▀',
24
28
  ];
25
29
  /** The brand line under the name. */
26
30
  export const TAGLINE = 'Your crew, ready for work.';
31
+ const CLOUD_WIDTH = Math.max(...CLOUD.map((line) => line.length));
32
+ /** The columns the cloud and the words beside it need. */
33
+ export const WELCOME_WIDTH = CLOUD_WIDTH + 3 + TAGLINE.length;
27
34
  /**
28
- * A blue between the brand's cobalt (light backgrounds) and its dark-mode blue, from the
29
- * 256-colour palette, so the cloud reads on light and dark terminals alike.
35
+ * Whether to draw the cloud: only in a terminal wide enough for it and the words beside it.
36
+ * Piped into a file or another program, or read out by a screen reader, it would only be
37
+ * noise. `columns` is undefined when the output isn't a terminal.
30
38
  */
31
- const CLOUD_COLOUR = '\x1b[38;5;69m';
39
+ export function showCloud(columns) {
40
+ return columns !== undefined && columns >= WELCOME_WIDTH;
41
+ }
32
42
  const BOLD = '\x1b[1m';
33
43
  const DIM = '\x1b[2m';
34
44
  const RESET = '\x1b[0m';
35
45
  /** The welcome screen: the cloud with the name and tagline beside it, then the install's status. */
36
46
  export function renderWelcome(options) {
47
+ return renderWelcomeHead(options) + renderWelcomeTail(options);
48
+ }
49
+ /** The row of the cloud with the eye in it, and the eye's columns. */
50
+ const EYE = { row: 5, from: 6, to: 8 };
51
+ function cloudRow(options, row, line) {
37
52
  const paint = (code, text) => (options.color ? `${code}${text}${RESET}` : text);
38
- const width = Math.max(...CLOUD.map((line) => line.length));
39
53
  const beside = {
40
54
  4: paint(BOLD, 'wheems') + ' ' + paint(DIM, options.version),
41
55
  5: TAGLINE,
42
56
  };
43
- const lines = CLOUD.map((line, row) => {
44
- const text = beside[row];
45
- const padded = line + ' '.repeat(width - line.length);
46
- return text === undefined
47
- ? paint(CLOUD_COLOUR, line)
48
- : `${paint(CLOUD_COLOUR, padded)} ${text}`;
49
- });
57
+ const text = beside[row];
58
+ return text === undefined ? line : `${line.padEnd(CLOUD_WIDTH)} ${text}`;
59
+ }
60
+ /** The top of the welcome screen: the cloud and the words beside it, or just the words. */
61
+ export function renderWelcomeHead(options) {
62
+ const paint = (code, text) => (options.color ? `${code}${text}${RESET}` : text);
63
+ const lines = options.cloud === false
64
+ ? [paint(BOLD, 'wheems') + ' ' + paint(DIM, options.version), TAGLINE]
65
+ : CLOUD.map((line, row) => cloudRow(options, row, line));
66
+ return ['', ...lines].join('\n') + '\n';
67
+ }
68
+ /** The rest of the welcome screen: the install's status and the commands to try. */
69
+ export function renderWelcomeTail(options) {
70
+ const paint = (code, text) => (options.color ? `${code}${text}${RESET}` : text);
50
71
  const marks = { '✓': '\x1b[32m', '!': '\x1b[33m', '-': DIM };
51
72
  return [
52
- '',
53
- ...lines,
54
73
  '',
55
74
  ...options.status.map((s) => `${paint(marks[s.mark], s.mark)} ${s.text}`),
56
75
  ...(options.status.length > 0 ? [''] : []),
@@ -59,6 +78,45 @@ export function renderWelcome(options) {
59
78
  : [paint(DIM, 'wheems setup · wheems doctor · wheems --help'), '']),
60
79
  ].join('\n');
61
80
  }
81
+ /**
82
+ * Whether the cloud may wink: only when it's drawn, so in a wide enough terminal, and not in
83
+ * CI, a dumb terminal, or for someone who has set WHEEMS_NO_ANIMATION.
84
+ */
85
+ export function canWink(columns, env) {
86
+ return (showCloud(columns) &&
87
+ env.TERM !== 'dumb' &&
88
+ (env.CI ?? '') === '' &&
89
+ (env.WHEEMS_NO_ANIMATION ?? '') === '');
90
+ }
91
+ /** How long the eye stays open before the wink, and how long it stays shut. */
92
+ export const WINK_TIMING = { before: 400, shut: 150 };
93
+ /**
94
+ * The cloud winks once: run straight after renderWelcomeHead is written, with the cursor on the
95
+ * line under the cloud. It redraws only the eye's row, hides the cursor meanwhile and always
96
+ * shows it again.
97
+ */
98
+ export async function wink(options) {
99
+ const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
100
+ const up = CLOUD.length - EYE.row;
101
+ const open = CLOUD[EYE.row] ?? '';
102
+ const shut = open.slice(0, EYE.from) + '▄▄' + open.slice(EYE.to);
103
+ // Up to the eye's row, clear it, draw it again, and back down to where the cursor was.
104
+ const redraw = (line) => {
105
+ options.write(`\x1b[${String(up)}A\r\x1b[2K${cloudRow(options, EYE.row, line)}\x1b[${String(up)}B\r`);
106
+ };
107
+ options.write(HIDE_CURSOR);
108
+ try {
109
+ await sleep(WINK_TIMING.before);
110
+ redraw(shut);
111
+ await sleep(WINK_TIMING.shut);
112
+ redraw(open);
113
+ }
114
+ finally {
115
+ options.write(SHOW_CURSOR);
116
+ }
117
+ }
118
+ export const HIDE_CURSOR = '\x1b[?25l';
119
+ export const SHOW_CURSOR = '\x1b[?25h';
62
120
  /**
63
121
  * What the welcome screen says about this install: its workspaces and agents, the service,
64
122
  * search, and anything waiting for review. Quick: it reads files and pings the service, and
@@ -11,3 +11,8 @@ export interface SlackConnectOptions {
11
11
  * its two tokens (checked with Slack), and say who may talk to agents.
12
12
  */
13
13
  export declare function slackConnectFlow(options?: SlackConnectOptions): ConnectFlow;
14
+ /**
15
+ * What's wrong with a pasted token, said so the person can fix it, or undefined if it looks
16
+ * right. It names a token only by its standard prefix, never by any other part of it.
17
+ */
18
+ export declare function tokenProblem(kind: 'bot' | 'app', value: string): string | undefined;
@@ -93,7 +93,8 @@ async function newTokens(ui, context, verify) {
93
93
  copied
94
94
  ? '2. Paste the manifest: it is on your clipboard.'
95
95
  : `2. Paste the manifest from ${manifestFile}`,
96
- '3. Install the app to the workspace, then copy the Bot User OAuth Token (xoxb-…).',
96
+ '3. Install the app to the workspace. Under OAuth & Permissions, copy the',
97
+ ' Bot User OAuth Token (xoxb-…).',
97
98
  '4. Under Basic Information → App-Level Tokens, generate a token with the',
98
99
  ' connections:write scope (xapp-…).',
99
100
  '',
@@ -106,11 +107,11 @@ async function newTokens(ui, context, verify) {
106
107
  for (;;) {
107
108
  const botToken = await ui.secret({
108
109
  message: 'Bot User OAuth Token',
109
- validate: (value) => value.trim().startsWith('xoxb-') ? undefined : 'The bot token starts with xoxb-',
110
+ validate: (value) => tokenProblem('bot', value),
110
111
  });
111
112
  const appToken = await ui.secret({
112
113
  message: 'App-Level Token',
113
- validate: (value) => value.trim().startsWith('xapp-') ? undefined : 'The app-level token starts with xapp-',
114
+ validate: (value) => tokenProblem('app', value),
114
115
  });
115
116
  try {
116
117
  await ui.task('Checking the tokens with Slack', () => verify(botToken.trim(), appToken.trim()), (id) => `Connected as ${id.botUser} in ${id.team}`);
@@ -123,4 +124,53 @@ async function newTokens(ui, context, verify) {
123
124
  }
124
125
  }
125
126
  }
127
+ /** What each token is called in Slack, where to find it, and how it starts. */
128
+ const TOKENS = {
129
+ bot: {
130
+ name: 'bot token',
131
+ prefix: 'xoxb-',
132
+ where: 'Under OAuth & Permissions, copy the Bot User OAuth Token',
133
+ },
134
+ app: {
135
+ name: 'app-level token',
136
+ prefix: 'xapp-',
137
+ where: 'Under Basic Information → App-Level Tokens, copy the token',
138
+ },
139
+ };
140
+ /** Other Slack tokens people paste by mistake, by their standard prefix. */
141
+ const OTHER_TOKENS = {
142
+ 'xoxp-': 'a user token',
143
+ 'xoxe-': 'a refresh token',
144
+ 'xoxa-': 'a workspace token',
145
+ };
146
+ const CLEAR = 'Clear the field with Backspace, then paste it once.';
147
+ /**
148
+ * What's wrong with a pasted token, said so the person can fix it, or undefined if it looks
149
+ * right. It names a token only by its standard prefix, never by any other part of it.
150
+ */
151
+ export function tokenProblem(kind, value) {
152
+ const want = TOKENS[kind];
153
+ const other = TOKENS[kind === 'bot' ? 'app' : 'bot'];
154
+ const token = value.trim();
155
+ const fix = `${want.where} (${want.prefix}…).`;
156
+ if (token === '')
157
+ return `Paste the ${want.name}. ${fix}`;
158
+ if (token.split(want.prefix).length > 2) {
159
+ return `That looks like more than one paste. ${CLEAR}`;
160
+ }
161
+ if (token.startsWith(want.prefix)) {
162
+ return /\s/.test(token) ? `There's a space or line break in it. ${CLEAR}` : undefined;
163
+ }
164
+ if (token.includes(want.prefix)) {
165
+ return `There's something before the ${want.prefix} at the start. ${CLEAR}`;
166
+ }
167
+ if (token.startsWith(other.prefix)) {
168
+ return `That starts with ${other.prefix}, so it's the ${other.name}. ${fix}`;
169
+ }
170
+ const known = Object.entries(OTHER_TOKENS).find(([prefix]) => token.startsWith(prefix));
171
+ if (known !== undefined) {
172
+ return `That starts with ${known[0]}, so it's ${known[1]}, not the ${want.name}. ${fix}`;
173
+ }
174
+ return `That isn't the ${want.name}: it starts with ${want.prefix}. ${fix}`;
175
+ }
126
176
  //# sourceMappingURL=connect.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wheems",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "An opinionated framework for running teams of AI agents, on Herdr core",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://wheems.dev",