lazy-skill 0.0.1 → 0.1.1
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/LICENSE +21 -0
- package/README.md +51 -8
- package/dist/adapters/base.js +18 -0
- package/dist/adapters/claude.js +27 -0
- package/dist/adapters/codex.js +27 -0
- package/dist/adapters/cursor.js +30 -0
- package/dist/adapters/detect.js +34 -0
- package/dist/adapters/index.js +25 -0
- package/dist/adapters/install-plan.js +105 -0
- package/dist/adapters/runner.js +139 -0
- package/dist/adapters/types.js +1 -0
- package/dist/commands/connect.js +153 -0
- package/dist/commands/disconnect.js +30 -0
- package/dist/commands/install.js +151 -0
- package/dist/commands/listen.js +162 -0
- package/dist/commands/skills.js +74 -0
- package/dist/commands/status.js +55 -0
- package/dist/commands/theme.js +50 -0
- package/dist/index.js +89 -0
- package/dist/lib/api.js +63 -0
- package/dist/lib/config.js +70 -0
- package/dist/lib/job-contract.js +74 -0
- package/dist/ui/box.js +56 -0
- package/dist/ui/messages.js +38 -0
- package/dist/ui/qr.js +59 -0
- package/dist/ui/spinner.js +42 -0
- package/dist/ui/theme.js +73 -0
- package/package.json +47 -8
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { api, ApiError } from "../lib/api.js";
|
|
2
|
+
import { clearCredentials, readConfig } from "../lib/config.js";
|
|
3
|
+
import { THEMES, style } from "../ui/theme.js";
|
|
4
|
+
export async function disconnectCommand() {
|
|
5
|
+
const config = readConfig();
|
|
6
|
+
const theme = THEMES[config.theme];
|
|
7
|
+
if (!config.deviceToken) {
|
|
8
|
+
console.log(`\n ${style.gray("Not connected. Nothing to disconnect.")}\n`);
|
|
9
|
+
return 0;
|
|
10
|
+
}
|
|
11
|
+
let serverMessage = "";
|
|
12
|
+
try {
|
|
13
|
+
await api("/api/cli/disconnect", { authed: true });
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
// The local credential is cleared regardless. Leaving a token on disk
|
|
17
|
+
// because the network was down would be the worse failure.
|
|
18
|
+
serverMessage =
|
|
19
|
+
err instanceof ApiError
|
|
20
|
+
? `Server not notified (${err.code}). Revoke the device from the app if needed.`
|
|
21
|
+
: "Server not notified.";
|
|
22
|
+
}
|
|
23
|
+
clearCredentials();
|
|
24
|
+
console.log(`\n ${style.green("✓")} Disconnected. Theme kept.`);
|
|
25
|
+
if (serverMessage)
|
|
26
|
+
console.log(` ${style.yellow("!")} ${serverMessage}`);
|
|
27
|
+
console.log(` ${style.gray("Run")} npx lazy-skill connect ${style.gray("to pair again.")}\n`);
|
|
28
|
+
void theme;
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { readConfig } from "../lib/config.js";
|
|
3
|
+
import { adapterFor, createInstallPlan, describePlan, detectAgents, InvalidPlanError, } from "../adapters/index.js";
|
|
4
|
+
import { THEMES, rgb, style } from "../ui/theme.js";
|
|
5
|
+
import { bottom, centered, mascot, row, top } from "../ui/box.js";
|
|
6
|
+
import { messages } from "../ui/messages.js";
|
|
7
|
+
const STAGE_LABEL = {
|
|
8
|
+
starting: "Preparing",
|
|
9
|
+
downloading: "Downloading",
|
|
10
|
+
installing: "Installing",
|
|
11
|
+
verifying: "Verifying",
|
|
12
|
+
done: "Done",
|
|
13
|
+
failed: "Failed",
|
|
14
|
+
};
|
|
15
|
+
const ORDER = ["downloading", "installing", "verifying"];
|
|
16
|
+
function progressBar(theme, stage) {
|
|
17
|
+
const reached = ORDER.indexOf(stage);
|
|
18
|
+
return ORDER.map((step, i) => {
|
|
19
|
+
const filled = reached >= i;
|
|
20
|
+
const bar = filled ? "████" : "░░░░";
|
|
21
|
+
return `${filled ? rgb(theme.accent, bar) : style.gray(bar)}`;
|
|
22
|
+
}).join(" ");
|
|
23
|
+
}
|
|
24
|
+
async function confirm(question) {
|
|
25
|
+
if (!process.stdin.isTTY)
|
|
26
|
+
return false;
|
|
27
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
28
|
+
try {
|
|
29
|
+
const answer = (await rl.question(` ${question} [y/N] `)).trim().toLowerCase();
|
|
30
|
+
return answer === "y" || answer === "yes";
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
rl.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Installs a skill locally through the one supported pathway.
|
|
38
|
+
*
|
|
39
|
+
* The user is shown the exact command before anything runs (§23), and only
|
|
40
|
+
* agents actually detected on this machine are offered — installing "for
|
|
41
|
+
* Cursor" on a computer without Cursor would be a lie.
|
|
42
|
+
*/
|
|
43
|
+
export async function installCommand(ref, options = { yes: false }) {
|
|
44
|
+
const theme = THEMES[readConfig().theme];
|
|
45
|
+
if (!ref) {
|
|
46
|
+
console.error(`\n ${style.red("✗")} Usage: lazy-skill install <owner/repo> [--agent claude]\n`);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const detected = await detectAgents();
|
|
50
|
+
const available = detected.filter((agent) => agent.detected);
|
|
51
|
+
if (available.length === 0) {
|
|
52
|
+
console.error(`\n ${style.yellow("!")} No supported AI tools were detected on this computer.`);
|
|
53
|
+
console.error(` ${style.gray("Install Claude Code, Codex or Cursor first.")}\n`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
const requested = options.agents
|
|
57
|
+
? options.agents.split(",").map((a) => a.trim().toLowerCase()).filter(Boolean)
|
|
58
|
+
: available.map((a) => a.id);
|
|
59
|
+
const targets = [];
|
|
60
|
+
for (const id of requested) {
|
|
61
|
+
const adapter = adapterFor(id);
|
|
62
|
+
if (!adapter) {
|
|
63
|
+
console.error(`\n ${style.red("✗")} Unknown agent "${id}".`);
|
|
64
|
+
console.error(` ${style.gray("Known:")} ${detected.map((a) => a.id).join(", ")}\n`);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
// Refuse to claim an install for something that is not here (§13).
|
|
68
|
+
if (!available.some((a) => a.id === adapter.id)) {
|
|
69
|
+
console.error(`\n ${style.red("✗")} ${adapter.label} was not detected on this computer.\n`);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
if (!targets.includes(adapter))
|
|
73
|
+
targets.push(adapter);
|
|
74
|
+
}
|
|
75
|
+
let plan;
|
|
76
|
+
try {
|
|
77
|
+
plan = createInstallPlan({
|
|
78
|
+
skillRef: ref,
|
|
79
|
+
agents: targets.map((t) => t.upstreamId),
|
|
80
|
+
scope: options.project ? "project" : "global",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const message = err instanceof InvalidPlanError ? err.message : "That request is not valid.";
|
|
85
|
+
console.error(`\n ${style.red("✗")} ${message}\n`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
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();
|
|
101
|
+
if (!options.yes && !(await confirm("Continue?"))) {
|
|
102
|
+
console.log(` ${style.gray("Cancelled. Nothing was installed.")}\n`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
console.log();
|
|
106
|
+
let lastStage = "starting";
|
|
107
|
+
const onProgress = (progress) => {
|
|
108
|
+
if (progress.stage === lastStage)
|
|
109
|
+
return;
|
|
110
|
+
lastStage = progress.stage;
|
|
111
|
+
if (progress.stage === "done" || progress.stage === "failed")
|
|
112
|
+
return;
|
|
113
|
+
const label = STAGE_LABEL[progress.stage] ?? progress.stage;
|
|
114
|
+
console.log(` ${label.padEnd(12)} ${progressBar(theme, progress.stage)}`);
|
|
115
|
+
};
|
|
116
|
+
// One adapter at a time, so a failure names the agent it belongs to.
|
|
117
|
+
const failures = [];
|
|
118
|
+
for (const adapter of targets) {
|
|
119
|
+
const outcome = await adapter.install(plan, onProgress);
|
|
120
|
+
if (!outcome.ok) {
|
|
121
|
+
failures.push(`${adapter.label}: ${outcome.error ?? "install failed"}`);
|
|
122
|
+
}
|
|
123
|
+
lastStage = "starting";
|
|
124
|
+
}
|
|
125
|
+
console.log();
|
|
126
|
+
if (failures.length > 0) {
|
|
127
|
+
console.log(` ${style.red("✗")} ${messages.failed()}`);
|
|
128
|
+
for (const failure of failures)
|
|
129
|
+
console.log(` ${style.gray(failure)}`);
|
|
130
|
+
console.log(` ${style.gray("We haven't blamed you yet.")}\n`);
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
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();
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { api, ApiError } from "../lib/api.js";
|
|
2
|
+
import { readConfig } from "../lib/config.js";
|
|
3
|
+
import { adapterFor, createInstallPlan, detectAgents, InvalidPlanError, } from "../adapters/index.js";
|
|
4
|
+
import { isJobCommand, parseInstallPayload, UnsafeJobError } from "../lib/job-contract.js";
|
|
5
|
+
import { THEMES, rgb, style } from "../ui/theme.js";
|
|
6
|
+
import { messages } from "../ui/messages.js";
|
|
7
|
+
const IDLE_POLL_MS = 3000;
|
|
8
|
+
const ERROR_BACKOFF_MS = 15_000;
|
|
9
|
+
function log(theme, line) {
|
|
10
|
+
const time = new Date().toLocaleTimeString();
|
|
11
|
+
console.log(` ${style.gray(time)} ${line}`);
|
|
12
|
+
void theme;
|
|
13
|
+
}
|
|
14
|
+
async function report(jobId, body) {
|
|
15
|
+
try {
|
|
16
|
+
await api("/api/cli/jobs/progress", { authed: true, body: { jobId, ...body } });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Progress is advisory. Losing a status update must not abort an install
|
|
20
|
+
// that is otherwise going fine.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Executes one job.
|
|
25
|
+
*
|
|
26
|
+
* Everything the server sent is re-validated here before anything runs. The
|
|
27
|
+
* server queues intent; this decides whether that intent is safe, and refuses
|
|
28
|
+
* it outright if not (§56).
|
|
29
|
+
*/
|
|
30
|
+
async function runJob(theme, job) {
|
|
31
|
+
if (!isJobCommand(job.command)) {
|
|
32
|
+
log(theme, `${style.red("✗")} refused unknown command "${String(job.command)}"`);
|
|
33
|
+
await report(job.id, { status: "failed", error: "Unknown command." });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (job.command !== "INSTALL_SKILL") {
|
|
37
|
+
// The other verbs are answered by the heartbeat rather than as jobs.
|
|
38
|
+
await report(job.id, { status: "succeeded", stage: "done" });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let payload;
|
|
42
|
+
try {
|
|
43
|
+
payload = parseInstallPayload(job.payload);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
const message = err instanceof UnsafeJobError ? err.message : "Malformed job.";
|
|
47
|
+
log(theme, `${style.red("✗")} ${message}`);
|
|
48
|
+
await report(job.id, { status: "failed", error: message });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Only install to agents this machine actually has. The server checks too,
|
|
52
|
+
// but the device is the only party that really knows (§13).
|
|
53
|
+
const detected = (await detectAgents()).filter((a) => a.detected).map((a) => a.id);
|
|
54
|
+
const targets = payload.agents.filter((agent) => detected.includes(agent));
|
|
55
|
+
if (targets.length === 0) {
|
|
56
|
+
const message = "None of the requested tools are installed on this computer.";
|
|
57
|
+
log(theme, `${style.yellow("!")} ${message}`);
|
|
58
|
+
await report(job.id, { status: "failed", error: message, agents: payload.agents });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
log(theme, `${rgb(theme.accent, "▸")} installing ${style.bold(payload.skillName)}`);
|
|
62
|
+
await report(job.id, { status: "running", stage: "starting" });
|
|
63
|
+
const failures = [];
|
|
64
|
+
for (const agentId of targets) {
|
|
65
|
+
const adapter = adapterFor(agentId);
|
|
66
|
+
if (!adapter)
|
|
67
|
+
continue;
|
|
68
|
+
let plan;
|
|
69
|
+
try {
|
|
70
|
+
plan = createInstallPlan({
|
|
71
|
+
skillRef: payload.skillRef,
|
|
72
|
+
agents: [adapter.upstreamId],
|
|
73
|
+
scope: payload.scope,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const message = err instanceof InvalidPlanError ? err.message : "Invalid plan.";
|
|
78
|
+
failures.push(`${adapter.label}: ${message}`);
|
|
79
|
+
await report(job.id, { status: "running", error: message, agents: [agentId] });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const onProgress = (progress) => {
|
|
83
|
+
if (progress.stage === "done" || progress.stage === "failed")
|
|
84
|
+
return;
|
|
85
|
+
void report(job.id, { status: "running", stage: progress.stage, agents: [agentId] });
|
|
86
|
+
};
|
|
87
|
+
const outcome = await adapter.install(plan, onProgress);
|
|
88
|
+
if (outcome.ok) {
|
|
89
|
+
log(theme, ` ${style.green("✓")} ${adapter.label}`);
|
|
90
|
+
await report(job.id, { status: "running", stage: "done", agents: [agentId] });
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const message = outcome.error ?? "Install failed.";
|
|
94
|
+
log(theme, ` ${style.red("✗")} ${adapter.label}: ${message}`);
|
|
95
|
+
failures.push(`${adapter.label}: ${message}`);
|
|
96
|
+
await report(job.id, { status: "running", stage: "failed", error: message, agents: [agentId] });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (failures.length === targets.length) {
|
|
100
|
+
await report(job.id, { status: "failed", stage: "failed", error: failures.join("; ") });
|
|
101
|
+
log(theme, `${style.red("✗")} ${messages.failed()}`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
await report(job.id, {
|
|
105
|
+
status: "succeeded",
|
|
106
|
+
stage: "done",
|
|
107
|
+
error: failures.length ? failures.join("; ") : undefined,
|
|
108
|
+
});
|
|
109
|
+
log(theme, `${style.green("✓")} done`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Waits for work from the app.
|
|
114
|
+
*
|
|
115
|
+
* Polling rather than a socket: it survives sleep, captive portals and
|
|
116
|
+
* proxies, and there is nothing to reconnect. The device authenticates every
|
|
117
|
+
* request, so no long-lived connection is holding a credential open.
|
|
118
|
+
*/
|
|
119
|
+
export async function listenCommand(options = {}) {
|
|
120
|
+
const config = readConfig();
|
|
121
|
+
const theme = THEMES[config.theme];
|
|
122
|
+
if (!config.deviceToken) {
|
|
123
|
+
console.error(`\n ${style.red("✗")} Not connected. Run ${style.bold("lazy-skill connect")} first.\n`);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
console.log();
|
|
127
|
+
console.log(` ${rgb(theme.accent, "●")} ${style.bold("Listening")} for installs from your phone.`);
|
|
128
|
+
console.log(` ${style.gray("Leave this running. Ctrl-C to stop.")}`);
|
|
129
|
+
console.log();
|
|
130
|
+
let stopped = false;
|
|
131
|
+
const stop = () => {
|
|
132
|
+
if (stopped)
|
|
133
|
+
return;
|
|
134
|
+
stopped = true;
|
|
135
|
+
console.log(`\n ${style.gray(messages.signOff())}\n`);
|
|
136
|
+
process.exit(0);
|
|
137
|
+
};
|
|
138
|
+
process.on("SIGINT", stop);
|
|
139
|
+
process.on("SIGTERM", stop);
|
|
140
|
+
let backoff = IDLE_POLL_MS;
|
|
141
|
+
while (!stopped) {
|
|
142
|
+
try {
|
|
143
|
+
const { job } = await api("/api/cli/jobs/claim", { authed: true });
|
|
144
|
+
backoff = IDLE_POLL_MS;
|
|
145
|
+
if (job)
|
|
146
|
+
await runJob(theme, job);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
150
|
+
console.error(`\n ${style.red("✗")} This computer was disconnected from the app.`);
|
|
151
|
+
console.error(` ${style.gray("Run")} lazy-skill connect ${style.gray("to pair again.")}\n`);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
// Network blips are expected on a laptop. Back off rather than spinning.
|
|
155
|
+
backoff = Math.min(backoff * 2, ERROR_BACKOFF_MS);
|
|
156
|
+
}
|
|
157
|
+
if (options.once)
|
|
158
|
+
return 0;
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
160
|
+
}
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homePath } from "../adapters/detect.js";
|
|
4
|
+
import { detectAgents } from "../adapters/index.js";
|
|
5
|
+
import { readConfig } from "../lib/config.js";
|
|
6
|
+
import { THEMES, style } from "../ui/theme.js";
|
|
7
|
+
import { bottom, centered, row, top, wordmark } from "../ui/box.js";
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
const SKILL_DIRS = {
|
|
13
|
+
claude: [homePath(".claude", "skills")],
|
|
14
|
+
codex: [homePath(".codex", "skills")],
|
|
15
|
+
cursor: [homePath(".cursor", "skills")],
|
|
16
|
+
};
|
|
17
|
+
function listSkillDirs(paths) {
|
|
18
|
+
const found = new Set();
|
|
19
|
+
for (const path of paths) {
|
|
20
|
+
try {
|
|
21
|
+
for (const entry of readdirSync(path)) {
|
|
22
|
+
if (entry.startsWith("."))
|
|
23
|
+
continue;
|
|
24
|
+
try {
|
|
25
|
+
if (statSync(join(path, entry)).isDirectory())
|
|
26
|
+
found.add(entry);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Unreadable entry — skip rather than guess.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Directory absent: the agent simply has no skills installed here.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return [...found].sort();
|
|
38
|
+
}
|
|
39
|
+
export async function skillsCommand() {
|
|
40
|
+
const theme = THEMES[readConfig().theme];
|
|
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));
|
|
50
|
+
let total = 0;
|
|
51
|
+
for (const agent of agents) {
|
|
52
|
+
if (!agent.detected) {
|
|
53
|
+
console.log(row(theme, `${agent.label.padEnd(10)} ${style.gray("not detected")}`));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const skills = listSkillDirs(SKILL_DIRS[agent.id] ?? []);
|
|
57
|
+
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}`)));
|
|
61
|
+
}
|
|
62
|
+
if (skills.length > 20) {
|
|
63
|
+
console.log(row(theme, style.gray(` … and ${skills.length - 20} more`)));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
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();
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { api, ApiError } from "../lib/api.js";
|
|
2
|
+
import { readConfig } from "../lib/config.js";
|
|
3
|
+
import { detectAgents } from "../adapters/index.js";
|
|
4
|
+
import { THEMES, style } from "../ui/theme.js";
|
|
5
|
+
import { bottom, centered, row, statusLine, top, wordmark } from "../ui/box.js";
|
|
6
|
+
export async function statusCommand() {
|
|
7
|
+
const config = readConfig();
|
|
8
|
+
const theme = THEMES[config.theme];
|
|
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));
|
|
16
|
+
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();
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
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;
|
|
29
|
+
let remoteError = null;
|
|
30
|
+
try {
|
|
31
|
+
remote = await api("/api/cli/status", {
|
|
32
|
+
authed: true,
|
|
33
|
+
body: { detectedAgents: agents.filter((a) => a.detected).map((a) => a.id) },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
remoteError = err instanceof ApiError ? err.message : "Could not reach the server.";
|
|
38
|
+
}
|
|
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));
|
|
44
|
+
for (const agent of agents) {
|
|
45
|
+
console.log(row(theme, statusLine(agent.label, agent.detected ? true : null)));
|
|
46
|
+
}
|
|
47
|
+
console.log(row(theme));
|
|
48
|
+
console.log(bottom(theme));
|
|
49
|
+
if (remoteError) {
|
|
50
|
+
console.log();
|
|
51
|
+
console.log(` ${style.gray(remoteError)}`);
|
|
52
|
+
}
|
|
53
|
+
console.log();
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { readConfig, updateConfig } from "../lib/config.js";
|
|
3
|
+
import { THEMES, THEME_IDS, rgb, style } from "../ui/theme.js";
|
|
4
|
+
/**
|
|
5
|
+
* Theme picker. The choice is stored locally and travels with the next
|
|
6
|
+
* pairing so the phone adopts the same look.
|
|
7
|
+
*/
|
|
8
|
+
export async function themeCommand(requested) {
|
|
9
|
+
const current = readConfig().theme;
|
|
10
|
+
if (requested) {
|
|
11
|
+
const match = THEME_IDS.find((id) => id === requested);
|
|
12
|
+
if (!match) {
|
|
13
|
+
console.error(`\n ${style.red("✗")} Unknown theme "${requested}".`);
|
|
14
|
+
console.error(` ${style.gray("Options:")} ${THEME_IDS.join(", ")}\n`);
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
updateConfig({ theme: match });
|
|
18
|
+
console.log(`\n ${style.green("✓")} Theme set to ${rgb(THEMES[match].accent, THEMES[match].label)}.\n`);
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
console.log(`\n ${style.bold("Pick a theme")}\n`);
|
|
22
|
+
THEME_IDS.forEach((id, i) => {
|
|
23
|
+
const t = THEMES[id];
|
|
24
|
+
const marker = id === current ? style.green("●") : style.gray("○");
|
|
25
|
+
console.log(` ${marker} ${i + 1}. ${rgb(t.accent, "████")} ${t.label}`);
|
|
26
|
+
});
|
|
27
|
+
console.log();
|
|
28
|
+
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`);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
33
|
+
try {
|
|
34
|
+
const answer = (await rl.question(" Number (enter to keep current): ")).trim();
|
|
35
|
+
if (!answer)
|
|
36
|
+
return 0;
|
|
37
|
+
const index = Number(answer) - 1;
|
|
38
|
+
if (!Number.isInteger(index) || index < 0 || index >= THEME_IDS.length) {
|
|
39
|
+
console.log(`\n ${style.yellow("!")} Not one of the options. Theme unchanged.\n`);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
const chosen = THEME_IDS[index];
|
|
43
|
+
updateConfig({ theme: chosen });
|
|
44
|
+
console.log(`\n ${style.green("✓")} Theme set to ${rgb(THEMES[chosen].accent, THEMES[chosen].label)}.\n`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
rl.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readConfig } from "./lib/config.js";
|
|
3
|
+
import { THEMES, rgb, style } from "./ui/theme.js";
|
|
4
|
+
import { connectCommand } from "./commands/connect.js";
|
|
5
|
+
import { disconnectCommand } from "./commands/disconnect.js";
|
|
6
|
+
import { statusCommand } from "./commands/status.js";
|
|
7
|
+
import { skillsCommand } from "./commands/skills.js";
|
|
8
|
+
import { installCommand } from "./commands/install.js";
|
|
9
|
+
import { themeCommand } from "./commands/theme.js";
|
|
10
|
+
import { listenCommand } from "./commands/listen.js";
|
|
11
|
+
const VERSION = "0.1.0";
|
|
12
|
+
function help() {
|
|
13
|
+
const theme = THEMES[readConfig().theme];
|
|
14
|
+
const cmd = (name) => rgb(theme.accent, name.padEnd(24));
|
|
15
|
+
console.log(`
|
|
16
|
+
${style.bold("LAZY")} ${rgb(theme.accent, style.bold("SKILL"))}${rgb(theme.support, " Zz")} ${style.gray("See it. Search it. Install it.")}
|
|
17
|
+
|
|
18
|
+
${style.bold("Usage")}
|
|
19
|
+
${cmd("lazy-skill connect")}Pair, then wait for installs
|
|
20
|
+
${cmd("lazy-skill listen")}Wait for installs sent from your phone
|
|
21
|
+
${cmd("lazy-skill status")}Show connection and detected tools
|
|
22
|
+
${cmd("lazy-skill skills")}List skills installed on this computer
|
|
23
|
+
${cmd("lazy-skill disconnect")}Revoke this computer
|
|
24
|
+
${cmd("lazy-skill theme [id]")}Pick a colour theme
|
|
25
|
+
${cmd("lazy-skill install <ref>")}Install a skill locally
|
|
26
|
+
|
|
27
|
+
${style.bold("Options")}
|
|
28
|
+
${cmd("--agent=<ids>")}Comma-separated agents to install to
|
|
29
|
+
${cmd("--no-listen")}Pair only; do not wait for installs
|
|
30
|
+
${cmd("-p, --project")}Install into this project, not globally
|
|
31
|
+
${cmd("-y, --yes")}Skip the confirmation prompt
|
|
32
|
+
${cmd("-v, --verbose")}Show how each tool was detected
|
|
33
|
+
${cmd("-h, --help")}This
|
|
34
|
+
${cmd("--version")}Print the version
|
|
35
|
+
|
|
36
|
+
${style.gray("Config:")} ~/.lazyskill/config.json
|
|
37
|
+
${style.gray("Server:")} set LAZY_SKILL_API_URL to point at a different host
|
|
38
|
+
`);
|
|
39
|
+
}
|
|
40
|
+
async function main() {
|
|
41
|
+
const argv = process.argv.slice(2);
|
|
42
|
+
const flags = new Set(argv.filter((a) => a.startsWith("-")));
|
|
43
|
+
const positional = argv.filter((a) => !a.startsWith("-"));
|
|
44
|
+
const command = positional[0];
|
|
45
|
+
if (flags.has("--version")) {
|
|
46
|
+
console.log(VERSION);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
if (!command || flags.has("-h") || flags.has("--help") || command === "help") {
|
|
50
|
+
help();
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
const verbose = flags.has("-v") || flags.has("--verbose");
|
|
54
|
+
switch (command) {
|
|
55
|
+
case "connect":
|
|
56
|
+
return connectCommand({
|
|
57
|
+
verbose,
|
|
58
|
+
// Listening is the default; --no-listen opts out.
|
|
59
|
+
listen: flags.has("--no-listen") ? false : true,
|
|
60
|
+
});
|
|
61
|
+
case "disconnect":
|
|
62
|
+
return disconnectCommand();
|
|
63
|
+
case "status":
|
|
64
|
+
return statusCommand();
|
|
65
|
+
case "listen":
|
|
66
|
+
return listenCommand({ once: flags.has("--once") });
|
|
67
|
+
case "skills":
|
|
68
|
+
return skillsCommand();
|
|
69
|
+
case "theme":
|
|
70
|
+
return themeCommand(positional[1]);
|
|
71
|
+
case "install": {
|
|
72
|
+
const agentFlag = argv.find((a) => a.startsWith("--agent="))?.split("=")[1];
|
|
73
|
+
const yes = flags.has("-y") || flags.has("--yes");
|
|
74
|
+
const project = flags.has("-p") || flags.has("--project");
|
|
75
|
+
return installCommand(positional[1], { agents: agentFlag, yes, project });
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
console.error(`\n ${style.red("✗")} Unknown command "${command}".\n`);
|
|
79
|
+
help();
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
main()
|
|
84
|
+
.then((code) => process.exit(code))
|
|
85
|
+
.catch((err) => {
|
|
86
|
+
// Never dump a stack at a user who just wanted to install something.
|
|
87
|
+
console.error(`\n ${style.red("✗")} ${err?.message ?? "Something went wrong."}\n`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
});
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { apiBaseUrl, readConfig } from "./config.js";
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
constructor(status, code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "ApiError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Talks to the Lazy Skill server. Every call is explicit about whether it
|
|
14
|
+
* carries the device token, so the credential is never sent to an endpoint
|
|
15
|
+
* that does not need it.
|
|
16
|
+
*/
|
|
17
|
+
export async function api(path, options = {}) {
|
|
18
|
+
const { body, authed = false, timeoutMs = 15_000 } = options;
|
|
19
|
+
const headers = { "Content-Type": "application/json" };
|
|
20
|
+
if (authed) {
|
|
21
|
+
const token = readConfig().deviceToken;
|
|
22
|
+
if (!token) {
|
|
23
|
+
throw new ApiError(401, "not_connected", "This computer is not connected yet.");
|
|
24
|
+
}
|
|
25
|
+
headers.Authorization = `Bearer ${token}`;
|
|
26
|
+
}
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`${apiBaseUrl()}${path}`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers,
|
|
33
|
+
body: JSON.stringify(body ?? {}),
|
|
34
|
+
signal: controller.signal,
|
|
35
|
+
});
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
const parsed = text ? safeJson(text) : null;
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const err = parsed;
|
|
40
|
+
throw new ApiError(res.status, err?.error ?? "http_error", err?.message ?? `Server returned ${res.status}.`);
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err instanceof ApiError)
|
|
46
|
+
throw err;
|
|
47
|
+
if (err.name === "AbortError") {
|
|
48
|
+
throw new ApiError(0, "timeout", "The server took too long to answer.");
|
|
49
|
+
}
|
|
50
|
+
throw new ApiError(0, "network", `Could not reach ${apiBaseUrl()}.`);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function safeJson(text) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|