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.
- package/dist/commands/connect.js +50 -77
- package/dist/commands/install.js +23 -34
- package/dist/commands/skills.js +18 -24
- package/dist/commands/status.js +21 -29
- package/dist/commands/theme.js +28 -13
- package/dist/index.js +10 -2
- package/dist/lib/config.js +4 -0
- package/dist/ui/layout.js +80 -0
- package/dist/ui/onboarding.js +47 -0
- package/dist/ui/qr.js +31 -19
- package/package.json +1 -1
- package/dist/ui/box.js +0 -56
package/dist/commands/connect.js
CHANGED
|
@@ -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,
|
|
6
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
42
|
+
muted(` ${agent.label}: ${agent.evidence}`);
|
|
50
43
|
}
|
|
51
|
-
console.log(row(theme));
|
|
52
44
|
}
|
|
53
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
86
|
-
let
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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) {
|
package/dist/commands/install.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
+
fail(messages.failed());
|
|
128
125
|
for (const failure of failures)
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
muted(failure);
|
|
127
|
+
blank();
|
|
131
128
|
return 1;
|
|
132
129
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
}
|
package/dist/commands/skills.js
CHANGED
|
@@ -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 {
|
|
7
|
+
import { blank, line, muted, status, welcome } from "../ui/layout.js";
|
|
8
8
|
/**
|
|
9
|
-
* Where each agent keeps installed skills.
|
|
10
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
for (const skill of skills.slice(0,
|
|
60
|
-
|
|
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 >
|
|
63
|
-
|
|
57
|
+
if (skills.length > 12) {
|
|
58
|
+
line(style.gray(` and ${skills.length - 12} more`));
|
|
64
59
|
}
|
|
65
60
|
}
|
|
66
|
-
|
|
67
|
-
if (total === 0)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
39
|
+
status(agent.label, agent.detected ? "ok" : "off");
|
|
46
40
|
}
|
|
47
|
-
|
|
48
|
-
console.log(bottom(theme));
|
|
41
|
+
blank();
|
|
49
42
|
if (remoteError) {
|
|
50
|
-
|
|
51
|
-
|
|
43
|
+
muted(remoteError);
|
|
44
|
+
blank();
|
|
52
45
|
}
|
|
53
|
-
console.log();
|
|
54
46
|
return 0;
|
|
55
47
|
}
|
package/dist/commands/theme.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
});
|
|
27
|
-
|
|
34
|
+
return `${marker} ${style.gray(String(i + 1))} ${rgb(t.accent, "████")} ${t.label}`;
|
|
35
|
+
}));
|
|
36
|
+
blank();
|
|
28
37
|
if (!process.stdin.isTTY) {
|
|
29
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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) {
|
package/dist/lib/config.js
CHANGED
|
@@ -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
|
|
14
|
-
* single module of padding
|
|
15
|
-
* patterns sit flush against surrounding
|
|
16
|
-
* scanning. Each rendered row is two modules tall
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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((
|
|
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((
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return
|
|
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
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
|
-
}
|