lazy-skill 0.2.1 → 0.3.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,9 @@ 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";
7
- import { renderQr } from "../ui/qr.js";
5
+ import { THEMES, style } from "../ui/theme.js";
6
+ import { renderFramedQr } from "../ui/qr.js";
7
+ import { blank, brand, fail, heading, line, muted, ok, status } from "../ui/layout.js";
8
8
  import { messages } from "../ui/messages.js";
9
9
  import { Spinner } from "../ui/spinner.js";
10
10
  function friendlyPlatform() {
@@ -14,43 +14,33 @@ function friendlyPlatform() {
14
14
  function deviceName() {
15
15
  return hostname().replace(/\.local$/, "").slice(0, 80) || "This computer";
16
16
  }
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));
17
+ function printAgents(agents) {
29
18
  for (const agent of agents) {
30
- console.log(row(theme, statusLine(agent.label, agent.detected ? true : null)));
19
+ status(agent.label, agent.detected ? "ok" : "off");
31
20
  }
32
21
  }
33
22
  export async function connectCommand(options) {
34
23
  const config = readConfig();
35
24
  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();
25
+ blank();
26
+ brand(theme);
27
+ muted("See it. Search it. Install it.");
28
+ blank();
29
+ // Detection runs before anything is drawn: a spinner writes to the current
30
+ // line, and anything transient printed mid-layout tears it.
31
+ const spinner = new Spinner(theme, "looking for your AI tools").start();
39
32
  const agents = await detectAgents();
40
33
  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));
34
+ spinner.stop();
35
+ printAgents(agents);
46
36
  if (options.verbose) {
37
+ blank();
47
38
  for (const agent of agents) {
48
39
  if (agent.evidence)
49
- console.log(row(theme, style.gray(` ${agent.label}: ${agent.evidence}`)));
40
+ muted(` ${agent.label}: ${agent.evidence}`);
50
41
  }
51
- console.log(row(theme));
52
42
  }
53
- // --- open a pairing session -----------------------------------------
43
+ blank();
54
44
  let session;
55
45
  try {
56
46
  session = await api("/api/pairing/start", {
@@ -64,26 +54,22 @@ export async function connectCommand(options) {
64
54
  });
65
55
  }
66
56
  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();
57
+ fail(err instanceof ApiError ? err.message : messages.failed());
58
+ muted("Set LAZY_SKILL_API_URL to point at a different server.");
59
+ blank();
73
60
  return 1;
74
61
  }
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 ---------------------------------------------
62
+ heading(theme, "Scan to connect");
63
+ muted("Open Lazy Skill on your phone and point the camera here.");
64
+ blank();
65
+ for (const row of await renderFramedQr(session.pairUrl))
66
+ console.log(row);
67
+ blank();
68
+ muted(session.pairUrl);
69
+ blank();
84
70
  const deadline = Date.now() + session.expiresInMs;
85
- const waitSpinner = new Spinner(theme, messages.waiting()).start();
86
- let lastNudge = Date.now();
71
+ const waiting = new Spinner(theme, "waiting for scan").start();
72
+ let nudgedAt = Date.now();
87
73
  let result = { status: "waiting" };
88
74
  while (Date.now() < deadline) {
89
75
  await sleep(1500);
@@ -97,13 +83,12 @@ export async function connectCommand(options) {
97
83
  }
98
84
  if (result.status !== "waiting")
99
85
  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();
86
+ if (Date.now() - nudgedAt > 15_000) {
87
+ waiting.update(messages.waiting());
88
+ nudgedAt = Date.now();
104
89
  }
105
90
  }
106
- waitSpinner.stop();
91
+ waiting.stop();
107
92
  if (result.status === "paired" && result.deviceToken) {
108
93
  updateConfig({
109
94
  deviceToken: result.deviceToken,
@@ -111,41 +96,25 @@ export async function connectCommand(options) {
111
96
  deviceName: result.device?.name ?? deviceName(),
112
97
  connectedAt: new Date().toISOString(),
113
98
  });
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.
99
+ ok(style.bold("Connected"));
100
+ line(style.gray(result.device?.name ?? deviceName()));
101
+ blank();
134
102
  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();
103
+ muted("Run lazy-skill when you want to install from your phone.");
104
+ blank();
137
105
  return 0;
138
106
  }
139
- console.log();
140
107
  const { listenCommand } = await import("./listen.js");
141
108
  return listenCommand();
142
109
  }
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();
110
+ if (result.status === "consumed") {
111
+ fail("That code was already used.");
112
+ }
113
+ else {
114
+ fail(messages.expired());
115
+ }
116
+ muted("Run lazy-skill for a fresh one.");
117
+ blank();
149
118
  return 1;
150
119
  }
151
120
  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, brand, fail, line, muted, ok, status } 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
+ brand(theme);
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, brand, line, muted, status } 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
+ brand(theme);
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, brand, line, muted, status } 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
+ brand(theme);
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
  }
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { installCommand } from "./commands/install.js";
9
9
  import { themeCommand } from "./commands/theme.js";
10
10
  import { listenCommand } from "./commands/listen.js";
11
11
  import { warnIfOutdated } from "./lib/version-check.js";
12
- const VERSION = "0.2.1";
12
+ const VERSION = "0.3.0";
13
13
  function help() {
14
14
  const theme = THEMES[readConfig().theme];
15
15
  const cmd = (name) => rgb(theme.accent, name.padEnd(26));
@@ -0,0 +1,68 @@
1
+ import { rgb, style, visibleWidth } from "./theme.js";
2
+ /**
3
+ * The CLI's visual language.
4
+ *
5
+ * No frames. The old design wrapped everything in a box, which meant every
6
+ * line carried two border glyphs and a fixed width, and the content had to
7
+ * fight for the space between them. Indentation and blank lines do the same
8
+ * grouping work without the noise, and they reflow on any terminal size.
9
+ */
10
+ const PAD = " ";
11
+ export function blank() {
12
+ console.log();
13
+ }
14
+ /** The wordmark. Two-tone, matching the web app. */
15
+ export function brand(theme) {
16
+ console.log(`${PAD}${style.bold("LAZY")} ${rgb(theme.accent, style.bold("SKILL"))}${rgb(theme.support, " Zz")}`);
17
+ }
18
+ /** A quiet line of supporting text. */
19
+ export function muted(text) {
20
+ console.log(`${PAD}${style.gray(text)}`);
21
+ }
22
+ /** A normal line. */
23
+ export function line(text = "") {
24
+ console.log(text ? `${PAD}${text}` : "");
25
+ }
26
+ /** A heading, used sparingly — one per screen at most. */
27
+ export function heading(theme, text) {
28
+ console.log(`${PAD}${rgb(theme.accent, style.bold(text))}`);
29
+ }
30
+ const DOT = {
31
+ ok: style.green("●"),
32
+ pending: style.yellow("●"),
33
+ off: style.gray("○"),
34
+ fail: style.red("●"),
35
+ };
36
+ /** `● Claude ready` — the dot carries the state, the word confirms it. */
37
+ export function status(label, tone, detail = "") {
38
+ const width = 10;
39
+ const name = label.length >= width ? label : label + " ".repeat(width - label.length);
40
+ const text = tone === "ok"
41
+ ? style.green(detail || "ready")
42
+ : tone === "fail"
43
+ ? style.red(detail || "failed")
44
+ : style.gray(detail || "not found");
45
+ console.log(`${PAD}${DOT[tone]} ${name} ${text}`);
46
+ }
47
+ /** A step in a sequence, numbered so the order is obvious. */
48
+ export function step(theme, n, text) {
49
+ console.log(`${PAD}${rgb(theme.accent, String(n))} ${text}`);
50
+ }
51
+ /** Something the user is meant to type or scan. */
52
+ export function emphasis(theme, text) {
53
+ console.log(`${PAD}${rgb(theme.accent, text)}`);
54
+ }
55
+ /** A short rule, only where a real break is needed. */
56
+ export function divider(width = 34) {
57
+ console.log(`${PAD}${style.gray("─".repeat(width))}`);
58
+ }
59
+ export function ok(text) {
60
+ console.log(`${PAD}${style.green("✓")} ${text}`);
61
+ }
62
+ export function warn(text) {
63
+ console.log(`${PAD}${style.yellow("!")} ${text}`);
64
+ }
65
+ export function fail(text) {
66
+ console.log(`${PAD}${style.red("✗")} ${text}`);
67
+ }
68
+ export { PAD, visibleWidth };
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.3.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
- }