lazy-skill 0.2.1 → 0.4.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.
@@ -2,9 +2,10 @@ import { hostname, platform, release } from "node:os";
2
2
  import { api, ApiError } from "../lib/api.js";
3
3
  import { readConfig, updateConfig } from "../lib/config.js";
4
4
  import { detectAgents } from "../adapters/index.js";
5
- import { THEMES, rgb, style } from "../ui/theme.js";
6
- import { bottom, centered, mascot, row, statusLine, top, wordmark } from "../ui/box.js";
5
+ import { THEMES, style } from "../ui/theme.js";
6
+ import { boxWidth, visibleWidth } from "../ui/layout.js";
7
7
  import { renderQr } from "../ui/qr.js";
8
+ import { blank, box, centre, fail, hint, line, muted, ok, status, welcome } from "../ui/layout.js";
8
9
  import { messages } from "../ui/messages.js";
9
10
  import { Spinner } from "../ui/spinner.js";
10
11
  function friendlyPlatform() {
@@ -14,43 +15,34 @@ function friendlyPlatform() {
14
15
  function deviceName() {
15
16
  return hostname().replace(/\.local$/, "").slice(0, 80) || "This computer";
16
17
  }
17
- function printHeader(theme) {
18
- console.log();
19
- console.log(top(theme));
20
- console.log(row(theme));
21
- for (const line of wordmark(theme))
22
- console.log(centered(theme, line));
23
- console.log(centered(theme, style.dim("Too lazy to install manually? Same.")));
24
- console.log(row(theme));
25
- }
26
- function printAgents(theme, agents) {
27
- console.log(row(theme, style.bold("Detected AI tools")));
28
- console.log(row(theme));
18
+ function printAgents(agents) {
29
19
  for (const agent of agents) {
30
- console.log(row(theme, statusLine(agent.label, agent.detected ? true : null)));
20
+ status(agent.label, agent.detected ? "ok" : "off");
31
21
  }
32
22
  }
33
23
  export async function connectCommand(options) {
34
24
  const config = readConfig();
35
25
  const theme = THEMES[config.theme];
36
- // Detection runs before the card is drawn. A spinner writes to the current
37
- // line, so anything transient printed between two card rows tears the frame.
38
- const detectSpinner = new Spinner(theme, messages.booting()[0]).start();
26
+ blank();
27
+ welcome(theme, "Welcome to Lazy Skill!");
28
+ blank();
29
+ hint("See it. Search it. Install it.");
30
+ blank();
31
+ // Detection runs before anything is drawn: a spinner writes to the current
32
+ // line, and anything transient printed mid-layout tears it.
33
+ const spinner = new Spinner(theme, "looking for your AI tools").start();
39
34
  const agents = await detectAgents();
40
35
  const detectedIds = agents.filter((a) => a.detected).map((a) => a.id);
41
- detectSpinner.stop();
42
- printHeader(theme);
43
- console.log(row(theme, `${style.gray(">")} ${messages.humanFound()} ${style.green("✓")}`));
44
- console.log(row(theme, `${style.gray(">")} ${messages.generating()}`));
45
- console.log(row(theme));
36
+ spinner.stop();
37
+ printAgents(agents);
46
38
  if (options.verbose) {
39
+ blank();
47
40
  for (const agent of agents) {
48
41
  if (agent.evidence)
49
- console.log(row(theme, style.gray(` ${agent.label}: ${agent.evidence}`)));
42
+ muted(` ${agent.label}: ${agent.evidence}`);
50
43
  }
51
- console.log(row(theme));
52
44
  }
53
- // --- open a pairing session -----------------------------------------
45
+ blank();
54
46
  let session;
55
47
  try {
56
48
  session = await api("/api/pairing/start", {
@@ -64,26 +56,24 @@ export async function connectCommand(options) {
64
56
  });
65
57
  }
66
58
  catch (err) {
67
- console.log(bottom(theme));
68
- console.log();
69
- const message = err instanceof ApiError ? err.message : messages.failed();
70
- console.error(` ${style.red("✗")} ${message}`);
71
- console.error(` ${style.gray("Set LAZY_SKILL_API_URL if you are pointing at a local server.")}`);
72
- console.log();
59
+ fail(err instanceof ApiError ? err.message : messages.failed());
60
+ muted("Set LAZY_SKILL_API_URL to point at a different server.");
61
+ blank();
73
62
  return 1;
74
63
  }
75
- console.log(bottom(theme));
76
- console.log();
77
- const qrLines = await renderQr(session.pairUrl);
78
- for (const line of qrLines)
79
- console.log(line);
80
- console.log(` ${style.bold("Scan with the Lazy Skill app")}`);
81
- console.log(` ${style.gray(session.pairUrl)}`);
82
- console.log();
83
- // --- wait for the phone ---------------------------------------------
64
+ hint("Open Lazy Skill on your phone and scan this.");
65
+ blank();
66
+ // The QR is the one thing to act on, so it gets the box. Its own quiet zone
67
+ // sits inside the border, untouched.
68
+ const plate = await renderQr(session.pairUrl);
69
+ const width = plate.length ? visibleWidth(plate[0]) + 4 : boxWidth();
70
+ box(theme, plate.map((row) => centre(row, width)), width);
71
+ blank();
72
+ muted(session.pairUrl);
73
+ blank();
84
74
  const deadline = Date.now() + session.expiresInMs;
85
- const waitSpinner = new Spinner(theme, messages.waiting()).start();
86
- let lastNudge = Date.now();
75
+ const waiting = new Spinner(theme, "waiting for scan").start();
76
+ let nudgedAt = Date.now();
87
77
  let result = { status: "waiting" };
88
78
  while (Date.now() < deadline) {
89
79
  await sleep(1500);
@@ -97,13 +87,12 @@ export async function connectCommand(options) {
97
87
  }
98
88
  if (result.status !== "waiting")
99
89
  break;
100
- // Rotate the waiting line occasionally so a long wait stays alive.
101
- if (Date.now() - lastNudge > 12_000) {
102
- waitSpinner.update(messages.waiting());
103
- lastNudge = Date.now();
90
+ if (Date.now() - nudgedAt > 15_000) {
91
+ waiting.update(messages.waiting());
92
+ nudgedAt = Date.now();
104
93
  }
105
94
  }
106
- waitSpinner.stop();
95
+ waiting.stop();
107
96
  if (result.status === "paired" && result.deviceToken) {
108
97
  updateConfig({
109
98
  deviceToken: result.deviceToken,
@@ -111,41 +100,25 @@ export async function connectCommand(options) {
111
100
  deviceName: result.device?.name ?? deviceName(),
112
101
  connectedAt: new Date().toISOString(),
113
102
  });
114
- console.log(` ${style.gray(">")} ${messages.scanned()} ${rgb(theme.support, "📱")}`);
115
- console.log(` ${style.gray(">")} ${messages.authenticating()} ${style.green("✓")}`);
116
- console.log();
117
- console.log(top(theme));
118
- console.log(row(theme));
119
- for (const line of mascot(theme, true))
120
- console.log(centered(theme, line));
121
- console.log(row(theme));
122
- console.log(centered(theme, rgb(theme.accent, style.bold(messages.connected()))));
123
- console.log(row(theme));
124
- printAgents(theme, agents);
125
- console.log(row(theme));
126
- console.log(centered(theme, style.dim(messages.signOff())));
127
- console.log(row(theme));
128
- console.log(bottom(theme));
129
- console.log();
130
- console.log(` ${style.gray("Credential stored in ~/.lazyskill/config.json (0600).")}`);
131
- // Keep listening by default. Someone who has just paired a computer and
132
- // reached for their phone wants an install to land, not a second command
133
- // to remember — a queued job with nothing polling for it just looks broken.
103
+ ok(style.bold("Connected"));
104
+ line(style.gray(result.device?.name ?? deviceName()));
105
+ blank();
134
106
  if (options.listen === false) {
135
- console.log(` ${style.gray("Run")} ${rgb(theme.accent, "lazy-skill listen")} ${style.gray("when you want to install from your phone.")}`);
136
- console.log();
107
+ muted("Run lazy-skill when you want to install from your phone.");
108
+ blank();
137
109
  return 0;
138
110
  }
139
- console.log();
140
111
  const { listenCommand } = await import("./listen.js");
141
112
  return listenCommand();
142
113
  }
143
- const reason = result.status === "consumed"
144
- ? "That code was already used."
145
- : messages.expired();
146
- console.log(` ${style.yellow("!")} ${reason}`);
147
- console.log(` ${style.gray("Run")} ${rgb(theme.accent, "npx lazy-skill connect")} ${style.gray("for a fresh one.")}`);
148
- console.log();
114
+ if (result.status === "consumed") {
115
+ fail("That code was already used.");
116
+ }
117
+ else {
118
+ fail(messages.expired());
119
+ }
120
+ muted("Run lazy-skill for a fresh one.");
121
+ blank();
149
122
  return 1;
150
123
  }
151
124
  function sleep(ms) {
@@ -2,7 +2,7 @@ import { createInterface } from "node:readline/promises";
2
2
  import { readConfig } from "../lib/config.js";
3
3
  import { adapterFor, createInstallPlan, describePlan, detectAgents, InvalidPlanError, } from "../adapters/index.js";
4
4
  import { THEMES, rgb, style } from "../ui/theme.js";
5
- import { bottom, centered, mascot, row, top } from "../ui/box.js";
5
+ import { blank, fail, line, muted, ok, status, welcome } from "../ui/layout.js";
6
6
  import { messages } from "../ui/messages.js";
7
7
  const STAGE_LABEL = {
8
8
  starting: "Preparing",
@@ -86,18 +86,15 @@ export async function installCommand(ref, options = { yes: false }) {
86
86
  return 1;
87
87
  }
88
88
  // Show exactly what will run, before it runs.
89
- console.log();
90
- console.log(top(theme));
91
- console.log(row(theme));
92
- console.log(row(theme, style.bold(`Install ${ref}`)));
93
- console.log(row(theme, `for ${targets.map((t) => t.label).join(", ")}`));
94
- console.log(row(theme, style.gray(options.project ? "scope: this project" : "scope: global")));
95
- console.log(row(theme));
96
- console.log(row(theme, style.gray("This will run:")));
97
- console.log(row(theme, rgb(theme.accent, describePlan(plan).slice(0, 48))));
98
- console.log(row(theme));
99
- console.log(bottom(theme));
100
- console.log();
89
+ blank();
90
+ welcome(theme, "Lazy Skill");
91
+ blank();
92
+ line(`${style.bold(ref)}`);
93
+ muted(`for ${targets.map((t) => t.label).join(", ")} · ${options.project ? "this project" : "global"}`);
94
+ blank();
95
+ muted("This will run:");
96
+ line(rgb(theme.accent, describePlan(plan)));
97
+ blank();
101
98
  if (!options.yes && !(await confirm("Continue?"))) {
102
99
  console.log(` ${style.gray("Cancelled. Nothing was installed.")}\n`);
103
100
  return 1;
@@ -111,7 +108,7 @@ export async function installCommand(ref, options = { yes: false }) {
111
108
  if (progress.stage === "done" || progress.stage === "failed")
112
109
  return;
113
110
  const label = STAGE_LABEL[progress.stage] ?? progress.stage;
114
- console.log(` ${label.padEnd(12)} ${progressBar(theme, progress.stage)}`);
111
+ line(`${style.gray(label.padEnd(12))} ${progressBar(theme, progress.stage)}`);
115
112
  };
116
113
  // One adapter at a time, so a failure names the agent it belongs to.
117
114
  const failures = [];
@@ -124,28 +121,20 @@ export async function installCommand(ref, options = { yes: false }) {
124
121
  }
125
122
  console.log();
126
123
  if (failures.length > 0) {
127
- console.log(` ${style.red("✗")} ${messages.failed()}`);
124
+ fail(messages.failed());
128
125
  for (const failure of failures)
129
- console.log(` ${style.gray(failure)}`);
130
- console.log(` ${style.gray("We haven't blamed you yet.")}\n`);
126
+ muted(failure);
127
+ blank();
131
128
  return 1;
132
129
  }
133
- console.log(top(theme));
134
- console.log(row(theme));
135
- for (const line of mascot(theme, true))
136
- console.log(centered(theme, line));
137
- console.log(row(theme));
138
- console.log(centered(theme, rgb(theme.accent, style.bold("INSTALLED!"))));
139
- console.log(row(theme));
140
- console.log(row(theme, `${ref}`));
141
- for (const target of targets) {
142
- console.log(row(theme, `${target.label.padEnd(12)} ${style.green("✓")}`));
143
- }
144
- console.log(row(theme));
145
- console.log(row(theme, `${style.gray("You contributed")} ${style.bold("0% effort")}`));
146
- console.log(row(theme, `${style.gray("Lazy Skill")} ${rgb(theme.accent, "100% effort")}`));
147
- console.log(row(theme));
148
- console.log(bottom(theme));
149
- console.log();
130
+ blank();
131
+ ok(style.bold("Installed"));
132
+ line(style.gray(ref));
133
+ blank();
134
+ for (const target of targets)
135
+ status(target.label, "ok", "installed");
136
+ blank();
137
+ muted("You contributed 0% effort. Lazy Skill did the rest.");
138
+ blank();
150
139
  return 0;
151
140
  }
@@ -4,10 +4,10 @@ import { homePath } from "../adapters/detect.js";
4
4
  import { detectAgents } from "../adapters/index.js";
5
5
  import { readConfig } from "../lib/config.js";
6
6
  import { THEMES, style } from "../ui/theme.js";
7
- import { bottom, centered, row, top, wordmark } from "../ui/box.js";
7
+ import { blank, line, muted, status, welcome } from "../ui/layout.js";
8
8
  /**
9
- * Where each agent keeps installed skills. These are read-only lookups — the
10
- * CLI lists what is on disk and never infers a skill that is not there.
9
+ * Where each agent keeps installed skills. Read-only lookups — the CLI lists
10
+ * what is on disk and never infers a skill that is not there.
11
11
  */
12
12
  const SKILL_DIRS = {
13
13
  claude: [homePath(".claude", "skills")],
@@ -39,36 +39,30 @@ function listSkillDirs(paths) {
39
39
  export async function skillsCommand() {
40
40
  const theme = THEMES[readConfig().theme];
41
41
  const agents = await detectAgents();
42
- console.log();
43
- console.log(top(theme));
44
- console.log(row(theme));
45
- for (const line of wordmark(theme))
46
- console.log(centered(theme, line));
47
- console.log(row(theme));
48
- console.log(row(theme, style.bold("Installed skills on this computer")));
49
- console.log(row(theme));
42
+ blank();
43
+ welcome(theme, "Lazy Skill");
44
+ blank();
50
45
  let total = 0;
51
46
  for (const agent of agents) {
52
47
  if (!agent.detected) {
53
- console.log(row(theme, `${agent.label.padEnd(10)} ${style.gray("not detected")}`));
48
+ status(agent.label, "off");
54
49
  continue;
55
50
  }
56
51
  const skills = listSkillDirs(SKILL_DIRS[agent.id] ?? []);
57
52
  total += skills.length;
58
- console.log(row(theme, `${style.bold(agent.label.padEnd(10))} ${skills.length ? style.green(`${skills.length}`) : style.gray("none found")}`));
59
- for (const skill of skills.slice(0, 20)) {
60
- console.log(row(theme, style.gray(` · ${skill}`)));
53
+ status(agent.label, "ok", skills.length ? `${skills.length} installed` : "none installed");
54
+ for (const skill of skills.slice(0, 12)) {
55
+ line(style.gray(` ${skill}`));
61
56
  }
62
- if (skills.length > 20) {
63
- console.log(row(theme, style.gray(`and ${skills.length - 20} more`)));
57
+ if (skills.length > 12) {
58
+ line(style.gray(` and ${skills.length - 12} more`));
64
59
  }
65
60
  }
66
- console.log(row(theme));
67
- if (total === 0) {
68
- console.log(row(theme, style.gray("Nothing installed yet. You haven't been lazy enough.")));
69
- console.log(row(theme));
70
- }
71
- console.log(bottom(theme));
72
- console.log();
61
+ blank();
62
+ if (total === 0)
63
+ muted("Nothing installed yet. You haven't been lazy enough.");
64
+ else
65
+ muted(`${total} skill${total === 1 ? "" : "s"} across your tools.`);
66
+ blank();
73
67
  return 0;
74
68
  }
@@ -2,54 +2,46 @@ import { api, ApiError } from "../lib/api.js";
2
2
  import { readConfig } from "../lib/config.js";
3
3
  import { detectAgents } from "../adapters/index.js";
4
4
  import { THEMES, style } from "../ui/theme.js";
5
- import { bottom, centered, row, statusLine, top, wordmark } from "../ui/box.js";
5
+ import { blank, line, muted, status, welcome } from "../ui/layout.js";
6
6
  export async function statusCommand() {
7
7
  const config = readConfig();
8
8
  const theme = THEMES[config.theme];
9
9
  const agents = await detectAgents();
10
- console.log();
11
- console.log(top(theme));
12
- console.log(row(theme));
13
- for (const line of wordmark(theme))
14
- console.log(centered(theme, line));
15
- console.log(row(theme));
10
+ blank();
11
+ welcome(theme, "Lazy Skill");
12
+ blank();
16
13
  if (!config.deviceToken) {
17
- console.log(row(theme, style.gray("Not connected.")));
18
- console.log(row(theme));
19
- console.log(row(theme, `Run ${style.bold("npx lazy-skill connect")}`));
20
- console.log(row(theme));
21
- console.log(bottom(theme));
22
- console.log();
14
+ status("computer", "off", "not connected");
15
+ blank();
16
+ muted("Run lazy-skill to pair.");
17
+ blank();
23
18
  return 1;
24
19
  }
25
- // Detection is local and always shown. The server round trip only confirms
26
- // the credential is still good, so a network failure downgrades the report
27
- // rather than hiding what we do know.
28
- let remote = null;
20
+ // Detection is local and always shown. The round trip only confirms the
21
+ // credential still works, so a network failure downgrades the report rather
22
+ // than hiding what we already know.
23
+ let reachable = false;
29
24
  let remoteError = null;
30
25
  try {
31
- remote = await api("/api/cli/status", {
26
+ await api("/api/cli/status", {
32
27
  authed: true,
33
28
  body: { detectedAgents: agents.filter((a) => a.detected).map((a) => a.id) },
34
29
  });
30
+ reachable = true;
35
31
  }
36
32
  catch (err) {
37
33
  remoteError = err instanceof ApiError ? err.message : "Could not reach the server.";
38
34
  }
39
- console.log(row(theme, `${style.bold(config.deviceName ?? "This computer")}`));
40
- console.log(row(theme, remote
41
- ? `${style.green("●")} Connected`
42
- : `${style.yellow("●")} Credential stored, server unreachable`));
43
- console.log(row(theme));
35
+ line(style.bold(config.deviceName ?? "This computer"));
36
+ status("connection", reachable ? "ok" : "pending", reachable ? "connected" : "stored, server unreachable");
37
+ blank();
44
38
  for (const agent of agents) {
45
- console.log(row(theme, statusLine(agent.label, agent.detected ? true : null)));
39
+ status(agent.label, agent.detected ? "ok" : "off");
46
40
  }
47
- console.log(row(theme));
48
- console.log(bottom(theme));
41
+ blank();
49
42
  if (remoteError) {
50
- console.log();
51
- console.log(` ${style.gray(remoteError)}`);
43
+ muted(remoteError);
44
+ blank();
52
45
  }
53
- console.log();
54
46
  return 0;
55
47
  }
@@ -1,47 +1,62 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { readConfig, updateConfig } from "../lib/config.js";
3
3
  import { THEMES, THEME_IDS, rgb, style } from "../ui/theme.js";
4
+ import { blank, box, muted, ok, welcome } from "../ui/layout.js";
4
5
  /**
5
6
  * Theme picker. The choice is stored locally and travels with the next
6
- * pairing so the phone adopts the same look.
7
+ * pairing, so the phone ends up on the same colour.
7
8
  */
8
9
  export async function themeCommand(requested) {
9
10
  const current = readConfig().theme;
11
+ const theme = THEMES[current];
10
12
  if (requested) {
11
13
  const match = THEME_IDS.find((id) => id === requested);
12
14
  if (!match) {
13
- console.error(`\n ${style.red("✗")} Unknown theme "${requested}".`);
14
- console.error(` ${style.gray("Options:")} ${THEME_IDS.join(", ")}\n`);
15
+ blank();
16
+ muted(`Unknown theme "${requested}".`);
17
+ muted(THEME_IDS.join(", "));
18
+ blank();
15
19
  return 1;
16
20
  }
17
21
  updateConfig({ theme: match });
18
- console.log(`\n ${style.green("✓")} Theme set to ${rgb(THEMES[match].accent, THEMES[match].label)}.\n`);
22
+ blank();
23
+ ok(`Theme set to ${rgb(THEMES[match].accent, THEMES[match].label)}.`);
24
+ muted("Connect again to carry it to your phone.");
25
+ blank();
19
26
  return 0;
20
27
  }
21
- console.log(`\n ${style.bold("Pick a theme")}\n`);
22
- THEME_IDS.forEach((id, i) => {
28
+ blank();
29
+ welcome(theme, "Pick a colour");
30
+ blank();
31
+ box(theme, THEME_IDS.map((id, i) => {
23
32
  const t = THEMES[id];
24
33
  const marker = id === current ? style.green("●") : style.gray("○");
25
- console.log(` ${marker} ${i + 1}. ${rgb(t.accent, "████")} ${t.label}`);
26
- });
27
- console.log();
34
+ return `${marker} ${style.gray(String(i + 1))} ${rgb(t.accent, "████")} ${t.label}`;
35
+ }));
36
+ blank();
28
37
  if (!process.stdin.isTTY) {
29
- console.log(` ${style.gray("Not a terminal — pass a theme id instead, e.g. lazy-skill theme matrix-green")}\n`);
38
+ muted("Not a terminal — pass an id, e.g. lazy-skill theme matrix-green");
39
+ blank();
30
40
  return 0;
31
41
  }
32
42
  const rl = createInterface({ input: process.stdin, output: process.stdout });
33
43
  try {
34
- const answer = (await rl.question(" Number (enter to keep current): ")).trim();
44
+ const answer = (await rl.question(` Number ${style.gray("(enter to keep)")} `)).trim();
35
45
  if (!answer)
36
46
  return 0;
37
47
  const index = Number(answer) - 1;
38
48
  if (!Number.isInteger(index) || index < 0 || index >= THEME_IDS.length) {
39
- console.log(`\n ${style.yellow("!")} Not one of the options. Theme unchanged.\n`);
49
+ blank();
50
+ muted("Not one of those. Theme unchanged.");
51
+ blank();
40
52
  return 1;
41
53
  }
42
54
  const chosen = THEME_IDS[index];
43
55
  updateConfig({ theme: chosen });
44
- console.log(`\n ${style.green("✓")} Theme set to ${rgb(THEMES[chosen].accent, THEMES[chosen].label)}.\n`);
56
+ blank();
57
+ ok(`Theme set to ${rgb(THEMES[chosen].accent, THEMES[chosen].label)}.`);
58
+ muted("Connect again to carry it to your phone.");
59
+ blank();
45
60
  return 0;
46
61
  }
47
62
  finally {
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readConfig } from "./lib/config.js";
2
+ import { isFirstRun, readConfig, updateConfig } from "./lib/config.js";
3
+ import { chooseTheme } from "./ui/onboarding.js";
3
4
  import { THEMES, rgb, style } from "./ui/theme.js";
4
5
  import { connectCommand } from "./commands/connect.js";
5
6
  import { disconnectCommand } from "./commands/disconnect.js";
@@ -9,7 +10,7 @@ import { installCommand } from "./commands/install.js";
9
10
  import { themeCommand } from "./commands/theme.js";
10
11
  import { listenCommand } from "./commands/listen.js";
11
12
  import { warnIfOutdated } from "./lib/version-check.js";
12
- const VERSION = "0.2.1";
13
+ const VERSION = "0.4.0";
13
14
  function help() {
14
15
  const theme = THEMES[readConfig().theme];
15
16
  const cmd = (name) => rgb(theme.accent, name.padEnd(26));
@@ -60,6 +61,13 @@ async function main() {
60
61
  // Only for the long-running commands: a stale copy matters most when the
61
62
  // user is about to sit and wait for something that will never work.
62
63
  const longRunning = !command || command === "connect" || command === "listen";
64
+ // Ask for a colour before anything is drawn, so the first thing the user
65
+ // sees is already in their theme — and so the phone has something to adopt
66
+ // when it pairs a moment later. Only on a genuinely first run, and never
67
+ // for `theme`, which asks on its own.
68
+ if (isFirstRun() && command !== "theme") {
69
+ updateConfig({ theme: await chooseTheme() });
70
+ }
63
71
  if (longRunning)
64
72
  await warnIfOutdated(VERSION, THEMES[readConfig().theme]);
65
73
  if (!command) {
@@ -8,6 +8,10 @@ const DEFAULTS = { theme: DEFAULT_THEME };
8
8
  export function configPath() {
9
9
  return FILE;
10
10
  }
11
+ /** True before the user has ever been asked anything. */
12
+ export function isFirstRun() {
13
+ return !existsSync(FILE);
14
+ }
11
15
  export function readConfig() {
12
16
  try {
13
17
  if (!existsSync(FILE))
@@ -0,0 +1,80 @@
1
+ import { rgb, style, visibleWidth } from "./theme.js";
2
+ /**
3
+ * The CLI's visual language.
4
+ *
5
+ * One box, around the thing the user is meant to act on — the QR, a prompt, a
6
+ * result. Everything else is indented text. Framing every line was the
7
+ * problem before: two border glyphs per row, a fixed width, and content
8
+ * squeezed between them.
9
+ */
10
+ export const PAD = " ";
11
+ /** Box width, clamped so it stays readable in a narrow terminal. */
12
+ export function boxWidth() {
13
+ const columns = process.stdout.columns ?? 80;
14
+ return Math.max(44, Math.min(72, columns - 4));
15
+ }
16
+ export function blank() {
17
+ console.log();
18
+ }
19
+ export function line(text = "") {
20
+ console.log(text ? `${PAD}${text}` : "");
21
+ }
22
+ export function muted(text) {
23
+ console.log(`${PAD}${style.gray(text)}`);
24
+ }
25
+ /** The welcome line. The mark, then the product, then a greeting. */
26
+ export function welcome(theme, text) {
27
+ console.log(`${PAD}${rgb(theme.accent, "✻")} ${style.bold(text)}`);
28
+ }
29
+ /** An indented hint under the welcome. */
30
+ export function hint(text) {
31
+ console.log(`${PAD} ${style.gray(text)}`);
32
+ }
33
+ const DOT = {
34
+ ok: style.green("●"),
35
+ pending: style.yellow("●"),
36
+ off: style.gray("○"),
37
+ fail: style.red("●"),
38
+ };
39
+ export function status(label, tone, detail = "") {
40
+ const width = 10;
41
+ const name = label.length >= width ? label : label + " ".repeat(width - label.length);
42
+ const text = tone === "ok"
43
+ ? style.green(detail || "ready")
44
+ : tone === "fail"
45
+ ? style.red(detail || "failed")
46
+ : style.gray(detail || "not found");
47
+ console.log(`${PAD}${DOT[tone]} ${name} ${text}`);
48
+ }
49
+ export function ok(text) {
50
+ console.log(`${PAD}${style.green("✓")} ${text}`);
51
+ }
52
+ export function warn(text) {
53
+ console.log(`${PAD}${style.yellow("!")} ${text}`);
54
+ }
55
+ export function fail(text) {
56
+ console.log(`${PAD}${style.red("✗")} ${text}`);
57
+ }
58
+ /**
59
+ * The one box.
60
+ *
61
+ * Rows are padded to a common width using their *visible* length, so colour
62
+ * codes inside the content cannot push the right border out of alignment.
63
+ */
64
+ export function box(theme, rows, width = boxWidth()) {
65
+ const edge = (left, right) => console.log(`${PAD}${rgb(theme.accent, left + "─".repeat(width - 2) + right)}`);
66
+ const bar = rgb(theme.accent, "│");
67
+ edge("╭", "╮");
68
+ for (const row of rows) {
69
+ const pad = Math.max(0, width - 4 - visibleWidth(row));
70
+ console.log(`${PAD}${bar} ${row}${" ".repeat(pad)} ${bar}`);
71
+ }
72
+ edge("╰", "╯");
73
+ }
74
+ /** Centres a row inside the box. */
75
+ export function centre(text, width = boxWidth()) {
76
+ const inner = width - 4;
77
+ const left = Math.max(0, Math.floor((inner - visibleWidth(text)) / 2));
78
+ return " ".repeat(left) + text;
79
+ }
80
+ export { visibleWidth };
@@ -0,0 +1,47 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { THEMES, THEME_IDS, rgb, style } from "./theme.js";
3
+ import { blank, box, hint, line, muted, welcome } from "./layout.js";
4
+ /**
5
+ * First-run theme choice.
6
+ *
7
+ * Asked once, before anything else, because the answer applies to both halves
8
+ * of the product: the terminal colours itself, and the phone adopts the same
9
+ * theme the moment it pairs. Choosing later in two places would be two
10
+ * chances to end up mismatched.
11
+ */
12
+ export async function chooseTheme() {
13
+ const fallback = "cyber-purple";
14
+ const theme = THEMES[fallback];
15
+ blank();
16
+ welcome(theme, "Welcome to Lazy Skill!");
17
+ blank();
18
+ hint("Pick a colour. Your phone will match it when you connect.");
19
+ blank();
20
+ box(theme, THEME_IDS.map((id, i) => {
21
+ const t = THEMES[id];
22
+ return `${style.gray(String(i + 1))} ${rgb(t.accent, "████")} ${t.label}`;
23
+ }));
24
+ blank();
25
+ // A pipe or a CI job has nobody to ask.
26
+ if (!process.stdin.isTTY) {
27
+ muted("Not a terminal — using Purple Night. Change it with: lazy-skill theme");
28
+ blank();
29
+ return fallback;
30
+ }
31
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
32
+ try {
33
+ const answer = (await rl.question(` Number ${style.gray("(enter for 1)")} `)).trim();
34
+ if (!answer)
35
+ return fallback;
36
+ const index = Number(answer) - 1;
37
+ if (!Number.isInteger(index) || index < 0 || index >= THEME_IDS.length) {
38
+ // Not worth a re-prompt: the choice is cosmetic and changeable.
39
+ line(style.gray(" Not one of those — using Purple Night."));
40
+ return fallback;
41
+ }
42
+ return THEME_IDS[index];
43
+ }
44
+ finally {
45
+ rl.close();
46
+ }
47
+ }
package/dist/ui/qr.js CHANGED
@@ -2,6 +2,18 @@ import QRCode from "qrcode";
2
2
  /** The terminal renderer paints black modules on a white background. */
3
3
  const QR_OPEN = "\x1b[47m\x1b[30m";
4
4
  const QR_CLOSE = "\x1b[0m";
5
+ // eslint-disable-next-line no-control-regex
6
+ const ANSI = /\x1b\[[0-9;]*m/g;
7
+ function stripAnsi(text) {
8
+ return text.replace(ANSI, "");
9
+ }
10
+ /** Visible cell count, ignoring escape sequences. */
11
+ export function visibleLength(text) {
12
+ return stripAnsi(text).length;
13
+ }
14
+ function escapeRe(text) {
15
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
+ }
5
17
  /**
6
18
  * Renders the pairing QR.
7
19
  *
@@ -10,12 +22,12 @@ const QR_CLOSE = "\x1b[0m";
10
22
  * reliability is not negotiable for decoration (§10).
11
23
  *
12
24
  * The quiet zone is added here rather than passed as an option: node-qrcode's
13
- * terminal renderer ignores `margin` when `small` is set, and ships only a
14
- * single module of padding. The standard wants four, and without it the finder
15
- * patterns sit flush against surrounding terminal text, which measurably hurts
16
- * scanning. Each rendered row is two modules tall (half-blocks), so two blank
17
- * rows above and below give the four modules vertically, and three extra
18
- * columns each side top up the one already present.
25
+ * terminal renderer ignores `margin` when `small` is set and ships only a
26
+ * single module of padding, where the standard wants four. Without it the
27
+ * finder patterns sit flush against surrounding text, which measurably hurts
28
+ * scanning. Each rendered row is two modules tall, so two blank rows above and
29
+ * below give four modules vertically, and three extra columns per side top up
30
+ * the one already present.
19
31
  */
20
32
  export async function renderQr(url) {
21
33
  const art = await QRCode.toString(url, {
@@ -25,11 +37,11 @@ export async function renderQr(url) {
25
37
  });
26
38
  const rows = art
27
39
  .split("\n")
28
- .filter((line) => stripAnsi(line).length > 0)
40
+ .filter((row) => stripAnsi(row).length > 0)
29
41
  // Drop only the wrapper this renderer adds. Interior codes are left alone:
30
42
  // the final half-row recolours per cell, and rewriting that would corrupt
31
43
  // the modules themselves.
32
- .map((line) => line.replace(new RegExp(`^${escapeRe(QR_OPEN)}`), "").replace(/\x1b\[0m$/, ""));
44
+ .map((row) => row.replace(new RegExp(`^${escapeRe(QR_OPEN)}`), "").replace(/\x1b\[0m$/, ""));
33
45
  if (rows.length === 0)
34
46
  return [];
35
47
  const SIDE = 3;
@@ -45,15 +57,15 @@ export async function renderQr(url) {
45
57
  });
46
58
  return [blank, blank, ...padded, blank, blank];
47
59
  }
48
- // eslint-disable-next-line no-control-regex
49
- const ANSI = /\x1b\[[0-9;]*m/g;
50
- function stripAnsi(text) {
51
- return text.replace(ANSI, "");
52
- }
53
- /** Visible cell count, ignoring escape sequences. */
54
- export function visibleLength(text) {
55
- return stripAnsi(text).length;
56
- }
57
- function escapeRe(text) {
58
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
60
+ /**
61
+ * The QR, indented to sit with the rest of the output.
62
+ *
63
+ * No frame. The white plate is already a clean, high-contrast rectangle with
64
+ * its own quiet zone — drawing rails around it added a border glyph to every
65
+ * single row and made the densest thing on screen denser still. Indentation
66
+ * places it; nothing else is needed.
67
+ */
68
+ export async function renderFramedQr(url) {
69
+ const plate = await renderQr(url);
70
+ return plate.map((row) => ` ${row}`);
59
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazy-skill",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Found an AI skill while scrolling? Install it to your computer from your phone.",
5
5
  "type": "module",
6
6
  "bin": {
package/dist/ui/box.js DELETED
@@ -1,56 +0,0 @@
1
- import { rgb, style, visibleWidth } from "./theme.js";
2
- /**
3
- * The framed card the CLI draws. Rounded corners and a themed border, to match
4
- * the cards on the brand board.
5
- */
6
- const B = {
7
- tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│",
8
- };
9
- export const CARD_WIDTH = 56;
10
- export function rule(theme, width = CARD_WIDTH) {
11
- return rgb(theme.accent, B.h.repeat(width - 2));
12
- }
13
- export function top(theme, width = CARD_WIDTH) {
14
- return rgb(theme.accent, B.tl + B.h.repeat(width - 2) + B.tr);
15
- }
16
- export function bottom(theme, width = CARD_WIDTH) {
17
- return rgb(theme.accent, B.bl + B.h.repeat(width - 2) + B.br);
18
- }
19
- /** A content row, padded to the card width using visible (not raw) length. */
20
- export function row(theme, content = "", width = CARD_WIDTH) {
21
- const inner = width - 4;
22
- const pad = Math.max(0, inner - visibleWidth(content));
23
- const edge = rgb(theme.accent, B.v);
24
- return `${edge} ${content}${" ".repeat(pad)} ${edge}`;
25
- }
26
- export function centered(theme, content, width = CARD_WIDTH) {
27
- const inner = width - 4;
28
- const len = visibleWidth(content);
29
- const left = Math.max(0, Math.floor((inner - len) / 2));
30
- return row(theme, " ".repeat(left) + content, width);
31
- }
32
- /**
33
- * The wordmark, in the same two-tone split the web app uses: LAZY in the
34
- * terminal's default ink, SKILL in the theme accent.
35
- */
36
- export function wordmark(theme) {
37
- return [
38
- `${style.bold("LAZY")} ${rgb(theme.accent, style.bold("SKILL"))}${rgb(theme.support, " Zz")}`,
39
- ];
40
- }
41
- /** Bit, small enough to sit inside a card. */
42
- export function mascot(theme, awake = false) {
43
- const hood = (t) => rgb(theme.accent, t);
44
- const eyes = awake ? "o o" : "- -";
45
- return [
46
- hood(" ▄███▄ "),
47
- hood(" █") + `\x1b[0m${eyes}` + hood("█ "),
48
- hood(" █ ") + "‿" + hood(" █ "),
49
- hood(" ▀███▀ "),
50
- ];
51
- }
52
- export function statusLine(label, ok) {
53
- const mark = ok === null ? style.gray("○") : ok ? style.green("✓") : style.gray("○");
54
- const text = ok === null ? style.gray("Not detected") : ok ? style.green("Ready") : style.gray("Not detected");
55
- return `${label.padEnd(12)} ${mark} ${text}`;
56
- }