lazy-skill 0.0.1 → 0.1.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/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 +149 -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 +85 -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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lazy Skill
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,15 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# lazy-skill
|
|
2
2
|
|
|
3
3
|
**See it. Search it. Install it.**
|
|
4
4
|
|
|
5
5
|
Found an AI skill while scrolling? Stop hunting for the repo, the README, and
|
|
6
|
-
the install command buried in step four.
|
|
6
|
+
the install command buried in step four. Search it on Lazy Skill and send it
|
|
7
|
+
straight to your computer — from your phone.
|
|
7
8
|
|
|
8
|
-
##
|
|
9
|
+
## Connect your computer
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
your time.
|
|
11
|
+
```bash
|
|
12
|
+
npx lazy-skill connect
|
|
13
|
+
```
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Scan the QR it prints with the Lazy Skill app. That's the whole setup.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx lazy-skill listen # wait for installs sent from your phone
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
| Command | What it does |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `lazy-skill connect` | Pair this computer with the app |
|
|
26
|
+
| `lazy-skill listen` | Wait for installs sent from your phone |
|
|
27
|
+
| `lazy-skill status` | Show the connection and detected tools |
|
|
28
|
+
| `lazy-skill skills` | List skills installed on this computer |
|
|
29
|
+
| `lazy-skill install <ref>` | Install a skill locally |
|
|
30
|
+
| `lazy-skill theme [id]` | Pick a colour theme |
|
|
31
|
+
| `lazy-skill disconnect` | Revoke this computer |
|
|
32
|
+
|
|
33
|
+
## Supported tools
|
|
34
|
+
|
|
35
|
+
Claude Code, Codex and Cursor. Detected by looking for them — never assumed.
|
|
36
|
+
Anything not found is reported as "Not detected", never as ready.
|
|
37
|
+
|
|
38
|
+
## What this does and does not do
|
|
39
|
+
|
|
40
|
+
Installation runs through [`skills`](https://www.npmjs.com/package/skills), the
|
|
41
|
+
upstream installer, at a pinned version.
|
|
42
|
+
|
|
43
|
+
**This is not a remote shell.** The server can queue one of four named
|
|
44
|
+
operations with structured parameters; it cannot send a command line, a path,
|
|
45
|
+
or a flag. Everything the server sends is re-validated on your machine before
|
|
46
|
+
anything runs, against a copy of the rules the CLI carries itself — so a
|
|
47
|
+
compromised server still cannot make your computer run something arbitrary.
|
|
48
|
+
|
|
49
|
+
Your device credential lives in `~/.lazyskill/config.json`, written `0600`.
|
|
50
|
+
Pairing codes are single-use and expire in two minutes.
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
Node.js 18 or newer.
|
|
55
|
+
|
|
56
|
+
## Licence
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createInstallPlan } from "./install-plan.js";
|
|
2
|
+
import { runInstall } from "./runner.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every adapter installs the same way — through the one supported pathway —
|
|
5
|
+
* so the mechanism lives here rather than being copied per agent. What differs
|
|
6
|
+
* per agent is its identifier and where it reads skills from, and those stay
|
|
7
|
+
* in the adapter.
|
|
8
|
+
*/
|
|
9
|
+
export function installVia(upstreamId, plan, onProgress) {
|
|
10
|
+
// Re-narrow the plan to this adapter's own agent. An adapter must never
|
|
11
|
+
// install to an agent it does not represent, even if handed a wider plan.
|
|
12
|
+
const scoped = createInstallPlan({
|
|
13
|
+
skillRef: plan.skillRef,
|
|
14
|
+
agents: [upstreamId],
|
|
15
|
+
scope: plan.scope,
|
|
16
|
+
});
|
|
17
|
+
return runInstall(scoped, onProgress);
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { anyExists, homePath, onPath } from "./detect.js";
|
|
2
|
+
import { installVia } from "./base.js";
|
|
3
|
+
/**
|
|
4
|
+
* Claude. Detected by its config directory or its binary on PATH — never
|
|
5
|
+
* assumed present (§13).
|
|
6
|
+
*/
|
|
7
|
+
export const claudeAdapter = {
|
|
8
|
+
id: "claude",
|
|
9
|
+
label: "Claude",
|
|
10
|
+
upstreamId: "claude-code",
|
|
11
|
+
skillsDir() {
|
|
12
|
+
return homePath(".claude", "skills");
|
|
13
|
+
},
|
|
14
|
+
async detect() {
|
|
15
|
+
const dir = anyExists([
|
|
16
|
+
homePath(".claude"),
|
|
17
|
+
]);
|
|
18
|
+
if (dir)
|
|
19
|
+
return { detected: true, evidence: dir };
|
|
20
|
+
if (await onPath("claude"))
|
|
21
|
+
return { detected: true, evidence: "claude on PATH" };
|
|
22
|
+
return { detected: false };
|
|
23
|
+
},
|
|
24
|
+
install(plan, onProgress) {
|
|
25
|
+
return installVia("claude-code", plan, onProgress);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { anyExists, homePath, onPath } from "./detect.js";
|
|
2
|
+
import { installVia } from "./base.js";
|
|
3
|
+
/**
|
|
4
|
+
* Codex. Detected by its config directory or its binary on PATH — never
|
|
5
|
+
* assumed present (§13).
|
|
6
|
+
*/
|
|
7
|
+
export const codexAdapter = {
|
|
8
|
+
id: "codex",
|
|
9
|
+
label: "Codex",
|
|
10
|
+
upstreamId: "codex",
|
|
11
|
+
skillsDir() {
|
|
12
|
+
return homePath(".codex", "skills");
|
|
13
|
+
},
|
|
14
|
+
async detect() {
|
|
15
|
+
const dir = anyExists([
|
|
16
|
+
homePath(".codex"),
|
|
17
|
+
]);
|
|
18
|
+
if (dir)
|
|
19
|
+
return { detected: true, evidence: dir };
|
|
20
|
+
if (await onPath("codex"))
|
|
21
|
+
return { detected: true, evidence: "codex on PATH" };
|
|
22
|
+
return { detected: false };
|
|
23
|
+
},
|
|
24
|
+
install(plan, onProgress) {
|
|
25
|
+
return installVia("codex", plan, onProgress);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { anyExists, homePath, onPath } from "./detect.js";
|
|
2
|
+
import { installVia } from "./base.js";
|
|
3
|
+
/**
|
|
4
|
+
* Cursor. Detected by its config directory or its binary on PATH — never
|
|
5
|
+
* assumed present (§13).
|
|
6
|
+
*/
|
|
7
|
+
export const cursorAdapter = {
|
|
8
|
+
id: "cursor",
|
|
9
|
+
label: "Cursor",
|
|
10
|
+
upstreamId: "cursor",
|
|
11
|
+
skillsDir() {
|
|
12
|
+
return homePath(".cursor", "skills");
|
|
13
|
+
},
|
|
14
|
+
async detect() {
|
|
15
|
+
const dir = anyExists([
|
|
16
|
+
homePath(".cursor"),
|
|
17
|
+
"/Applications/Cursor.app",
|
|
18
|
+
homePath("Applications", "Cursor.app"),
|
|
19
|
+
homePath("AppData", "Local", "Programs", "cursor"),
|
|
20
|
+
]);
|
|
21
|
+
if (dir)
|
|
22
|
+
return { detected: true, evidence: dir };
|
|
23
|
+
if (await onPath("cursor"))
|
|
24
|
+
return { detected: true, evidence: "cursor on PATH" };
|
|
25
|
+
return { detected: false };
|
|
26
|
+
},
|
|
27
|
+
install(plan, onProgress) {
|
|
28
|
+
return installVia("cursor", plan, onProgress);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
/** True when `name` resolves on PATH. */
|
|
8
|
+
export async function onPath(name) {
|
|
9
|
+
// A fixed lookup tool with the name passed as an argument — never a shell
|
|
10
|
+
// string, so a crafted name cannot become a command.
|
|
11
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await run(finder, [name], { timeout: 3000 });
|
|
14
|
+
return stdout.trim().length > 0;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function homePath(...segments) {
|
|
21
|
+
return join(homedir(), ...segments);
|
|
22
|
+
}
|
|
23
|
+
export function anyExists(paths) {
|
|
24
|
+
for (const path of paths) {
|
|
25
|
+
try {
|
|
26
|
+
if (existsSync(path))
|
|
27
|
+
return path;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Unreadable path is not a detection.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { claudeAdapter } from "./claude.js";
|
|
2
|
+
import { codexAdapter } from "./codex.js";
|
|
3
|
+
import { cursorAdapter } from "./cursor.js";
|
|
4
|
+
export { createInstallPlan, describePlan, isValidSkillRef, InvalidPlanError, UPSTREAM_AGENTS, } from "./install-plan.js";
|
|
5
|
+
export const ADAPTERS = [claudeAdapter, codexAdapter, cursorAdapter];
|
|
6
|
+
export function adapterFor(id) {
|
|
7
|
+
return ADAPTERS.find((adapter) => adapter.id === id) ?? null;
|
|
8
|
+
}
|
|
9
|
+
/** Runs every detector in parallel. A detector that throws counts as absent. */
|
|
10
|
+
export async function detectAgents() {
|
|
11
|
+
return Promise.all(ADAPTERS.map(async (adapter) => {
|
|
12
|
+
try {
|
|
13
|
+
const result = await adapter.detect();
|
|
14
|
+
return {
|
|
15
|
+
id: adapter.id,
|
|
16
|
+
label: adapter.label,
|
|
17
|
+
detected: result.detected,
|
|
18
|
+
evidence: result.evidence,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { id: adapter.id, label: adapter.label, detected: false };
|
|
23
|
+
}
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a request into a command, safely.
|
|
3
|
+
*
|
|
4
|
+
* This module is the entire trust boundary for installation. A skill
|
|
5
|
+
* reference may originate from a phone, a server, or a shell argument, so it
|
|
6
|
+
* is validated against a strict pattern here and only ever emitted as a
|
|
7
|
+
* distinct element of an argv array — never interpolated into a string.
|
|
8
|
+
*
|
|
9
|
+
* Nothing in this file executes anything. Building a plan and running one are
|
|
10
|
+
* deliberately separate, so the dangerous part stays pure and testable.
|
|
11
|
+
*/
|
|
12
|
+
/** Agents the upstream installer accepts. Verified against skills@1.5.23. */
|
|
13
|
+
export const UPSTREAM_AGENTS = [
|
|
14
|
+
"claude-code",
|
|
15
|
+
"codex",
|
|
16
|
+
"cursor",
|
|
17
|
+
"copilot",
|
|
18
|
+
"windsurf",
|
|
19
|
+
"gemini",
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* The installer this CLI delegates to, pinned.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately not a floating tag: an unpinned `npx` fetches and executes
|
|
25
|
+
* whatever the registry serves at that moment. Bump this on purpose, after
|
|
26
|
+
* looking at what changed.
|
|
27
|
+
*/
|
|
28
|
+
export const UPSTREAM_PACKAGE = "skills@1.5.23";
|
|
29
|
+
/**
|
|
30
|
+
* A skill reference: `owner/repo`, optionally `owner/repo/skill`.
|
|
31
|
+
*
|
|
32
|
+
* Anchored, no dots, no leading dashes. That last part matters most: a
|
|
33
|
+
* reference beginning with `-` would be read by the installer as a flag
|
|
34
|
+
* rather than a package.
|
|
35
|
+
*/
|
|
36
|
+
const REF_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]*(?:\/[A-Za-z0-9_][A-Za-z0-9_.-]*){1,2}$/;
|
|
37
|
+
const MAX_REF_LENGTH = 200;
|
|
38
|
+
export class InvalidPlanError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "InvalidPlanError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function isValidSkillRef(value) {
|
|
45
|
+
return (typeof value === "string" &&
|
|
46
|
+
value.length > 0 &&
|
|
47
|
+
value.length <= MAX_REF_LENGTH &&
|
|
48
|
+
REF_PATTERN.test(value) &&
|
|
49
|
+
// Path traversal would escape the skills directory entirely.
|
|
50
|
+
!value.includes(".."));
|
|
51
|
+
}
|
|
52
|
+
export function isUpstreamAgent(value) {
|
|
53
|
+
return typeof value === "string" && UPSTREAM_AGENTS.includes(value);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Validates a request into a plan. Throws rather than sanitising: a reference
|
|
57
|
+
* we had to repair is a reference we do not understand, and quietly installing
|
|
58
|
+
* something adjacent to what was asked for is worse than refusing.
|
|
59
|
+
*/
|
|
60
|
+
export function createInstallPlan(input) {
|
|
61
|
+
if (!isValidSkillRef(input.skillRef)) {
|
|
62
|
+
throw new InvalidPlanError("That skill reference is not valid. Expected something like owner/repo.");
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(input.agents) || input.agents.length === 0) {
|
|
65
|
+
throw new InvalidPlanError("No target agent was given.");
|
|
66
|
+
}
|
|
67
|
+
const agents = [];
|
|
68
|
+
for (const agent of input.agents) {
|
|
69
|
+
if (!isUpstreamAgent(agent)) {
|
|
70
|
+
throw new InvalidPlanError(`Unknown agent "${String(agent)}".`);
|
|
71
|
+
}
|
|
72
|
+
if (!agents.includes(agent))
|
|
73
|
+
agents.push(agent);
|
|
74
|
+
}
|
|
75
|
+
const scope = input.scope === "project" ? "project" : "global";
|
|
76
|
+
return { skillRef: input.skillRef, agents, scope };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The exact argv passed to the installer.
|
|
80
|
+
*
|
|
81
|
+
* Every element is either a fixed literal or a value already validated above,
|
|
82
|
+
* and the runner spawns with `shell: false`, so no element is ever parsed by a
|
|
83
|
+
* shell. `--` terminates option parsing so the reference cannot be read as a
|
|
84
|
+
* flag even if the pattern above is ever loosened.
|
|
85
|
+
*/
|
|
86
|
+
export function buildInstallArgs(plan) {
|
|
87
|
+
const args = [
|
|
88
|
+
"--yes",
|
|
89
|
+
UPSTREAM_PACKAGE,
|
|
90
|
+
"add",
|
|
91
|
+
"--agent",
|
|
92
|
+
plan.agents.join(","),
|
|
93
|
+
"--skill",
|
|
94
|
+
"*",
|
|
95
|
+
"--yes",
|
|
96
|
+
];
|
|
97
|
+
if (plan.scope === "global")
|
|
98
|
+
args.push("--global");
|
|
99
|
+
args.push("--", plan.skillRef);
|
|
100
|
+
return args;
|
|
101
|
+
}
|
|
102
|
+
/** What the user is shown before anything runs (§23). */
|
|
103
|
+
export function describePlan(plan) {
|
|
104
|
+
return `npx ${buildInstallArgs(plan).join(" ")}`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { buildInstallArgs, describePlan } from "./install-plan.js";
|
|
3
|
+
/** Upstream prints spinners and colour; neither belongs in our progress. */
|
|
4
|
+
// eslint-disable-next-line no-control-regex
|
|
5
|
+
const CONTROL = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|[\r\x00-\x08\x0b\x0c\x0e-\x1f]/g;
|
|
6
|
+
function clean(chunk) {
|
|
7
|
+
return chunk
|
|
8
|
+
.replace(CONTROL, "")
|
|
9
|
+
.split("\n")
|
|
10
|
+
.map((line) => line.replace(/^[│◇◆●○◒◐◓◑✓✔✗✘|\s]+/, "").trim())
|
|
11
|
+
.filter((line) => line.length > 0);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Maps observed output to a stage, matched against the installer's real
|
|
15
|
+
* markers rather than loose keywords.
|
|
16
|
+
*
|
|
17
|
+
* The specificity matters: the very first line upstream prints is
|
|
18
|
+
* "Agent detected — installing non-interactively", which a bare "installing"
|
|
19
|
+
* test reads as the install stage before anything has been downloaded.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately conservative — an unrecognised line advances nothing rather
|
|
22
|
+
* than inventing progress the installer never reported (§18).
|
|
23
|
+
*/
|
|
24
|
+
export function stageFor(line) {
|
|
25
|
+
const text = line.toLowerCase();
|
|
26
|
+
// The startup banner is not a stage.
|
|
27
|
+
if (text.includes("non-interactively"))
|
|
28
|
+
return null;
|
|
29
|
+
if (text.includes("source:") || text.includes("fetching") || text.includes("cloning")) {
|
|
30
|
+
return "downloading";
|
|
31
|
+
}
|
|
32
|
+
if (/installing (all |\d)/.test(text) || text.includes("copy →") || text.includes("link →")) {
|
|
33
|
+
return "installing";
|
|
34
|
+
}
|
|
35
|
+
if (text.includes("installation summary") ||
|
|
36
|
+
text.includes("installed") ||
|
|
37
|
+
text.includes("added")) {
|
|
38
|
+
return "verifying";
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/** Stage ordering, used to keep reported progress monotonic. */
|
|
43
|
+
const STAGE_RANK = {
|
|
44
|
+
starting: 0,
|
|
45
|
+
downloading: 1,
|
|
46
|
+
installing: 2,
|
|
47
|
+
verifying: 3,
|
|
48
|
+
done: 4,
|
|
49
|
+
failed: 5,
|
|
50
|
+
};
|
|
51
|
+
export const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
52
|
+
export async function runInstall(plan, onProgress, options = {}) {
|
|
53
|
+
const args = buildInstallArgs(plan);
|
|
54
|
+
const output = [];
|
|
55
|
+
onProgress({ stage: "starting", detail: describePlan(plan) });
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const child = spawn("npx", args, {
|
|
58
|
+
// No shell. The single most important line in this file.
|
|
59
|
+
shell: false,
|
|
60
|
+
cwd: options.cwd ?? process.cwd(),
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
env: {
|
|
63
|
+
...process.env,
|
|
64
|
+
// Keep the child non-interactive and machine-readable. FORCE_COLOR is
|
|
65
|
+
// removed rather than overridden: it wins over NO_COLOR downstream, so
|
|
66
|
+
// inheriting a developer's FORCE_COLOR would put escape sequences back
|
|
67
|
+
// into the output this parser reads.
|
|
68
|
+
FORCE_COLOR: undefined,
|
|
69
|
+
CI: "1",
|
|
70
|
+
NO_COLOR: "1",
|
|
71
|
+
npm_config_yes: "true",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
let settled = false;
|
|
75
|
+
let lastStage = "starting";
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
if (settled)
|
|
78
|
+
return;
|
|
79
|
+
settled = true;
|
|
80
|
+
child.kill("SIGTERM");
|
|
81
|
+
// A wedged installer must not hold the device forever.
|
|
82
|
+
setTimeout(() => child.kill("SIGKILL"), 5000).unref();
|
|
83
|
+
onProgress({ stage: "failed", detail: "Timed out." });
|
|
84
|
+
resolve({
|
|
85
|
+
ok: false,
|
|
86
|
+
exitCode: null,
|
|
87
|
+
output,
|
|
88
|
+
error: "The installer took too long and was stopped.",
|
|
89
|
+
});
|
|
90
|
+
}, options.timeoutMs ?? INSTALL_TIMEOUT_MS);
|
|
91
|
+
const consume = (chunk) => {
|
|
92
|
+
for (const line of clean(chunk.toString())) {
|
|
93
|
+
// Bounded: a chatty installer must not grow memory without limit.
|
|
94
|
+
output.push(line);
|
|
95
|
+
if (output.length > 200)
|
|
96
|
+
output.shift();
|
|
97
|
+
// Only ever move forward. The installer interleaves its own phases
|
|
98
|
+
// (it prints an "installing" banner before it starts fetching), and a
|
|
99
|
+
// progress bar that slides backwards reads as a fault.
|
|
100
|
+
const next = stageFor(line);
|
|
101
|
+
if (next && STAGE_RANK[next] > STAGE_RANK[lastStage]) {
|
|
102
|
+
lastStage = next;
|
|
103
|
+
onProgress({ stage: next, detail: line });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
child.stdout?.on("data", consume);
|
|
108
|
+
child.stderr?.on("data", consume);
|
|
109
|
+
child.on("error", (err) => {
|
|
110
|
+
if (settled)
|
|
111
|
+
return;
|
|
112
|
+
settled = true;
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
onProgress({ stage: "failed", detail: err.message });
|
|
115
|
+
resolve({
|
|
116
|
+
ok: false,
|
|
117
|
+
exitCode: null,
|
|
118
|
+
output,
|
|
119
|
+
error: err.code === "ENOENT"
|
|
120
|
+
? "npx was not found on this computer. Node.js is required."
|
|
121
|
+
: err.message,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
child.on("close", (code) => {
|
|
125
|
+
if (settled)
|
|
126
|
+
return;
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
const ok = code === 0;
|
|
130
|
+
onProgress({ stage: ok ? "done" : "failed" });
|
|
131
|
+
resolve({
|
|
132
|
+
ok,
|
|
133
|
+
exitCode: code,
|
|
134
|
+
output,
|
|
135
|
+
error: ok ? undefined : output.at(-1) ?? `Installer exited with code ${code}.`,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { hostname, platform, release } from "node:os";
|
|
2
|
+
import { api, ApiError } from "../lib/api.js";
|
|
3
|
+
import { readConfig, updateConfig } from "../lib/config.js";
|
|
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";
|
|
8
|
+
import { messages } from "../ui/messages.js";
|
|
9
|
+
import { Spinner } from "../ui/spinner.js";
|
|
10
|
+
function friendlyPlatform() {
|
|
11
|
+
const p = platform();
|
|
12
|
+
return p === "darwin" || p === "win32" || p === "linux" ? p : "unknown";
|
|
13
|
+
}
|
|
14
|
+
function deviceName() {
|
|
15
|
+
return hostname().replace(/\.local$/, "").slice(0, 80) || "This computer";
|
|
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));
|
|
29
|
+
for (const agent of agents) {
|
|
30
|
+
console.log(row(theme, statusLine(agent.label, agent.detected ? true : null)));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function connectCommand(options) {
|
|
34
|
+
const config = readConfig();
|
|
35
|
+
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();
|
|
39
|
+
const agents = await detectAgents();
|
|
40
|
+
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));
|
|
46
|
+
if (options.verbose) {
|
|
47
|
+
for (const agent of agents) {
|
|
48
|
+
if (agent.evidence)
|
|
49
|
+
console.log(row(theme, style.gray(` ${agent.label}: ${agent.evidence}`)));
|
|
50
|
+
}
|
|
51
|
+
console.log(row(theme));
|
|
52
|
+
}
|
|
53
|
+
// --- open a pairing session -----------------------------------------
|
|
54
|
+
let session;
|
|
55
|
+
try {
|
|
56
|
+
session = await api("/api/pairing/start", {
|
|
57
|
+
body: {
|
|
58
|
+
deviceName: deviceName(),
|
|
59
|
+
platform: friendlyPlatform(),
|
|
60
|
+
osVersion: release(),
|
|
61
|
+
detectedAgents: detectedIds,
|
|
62
|
+
theme: theme.id,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
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();
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
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 ---------------------------------------------
|
|
84
|
+
const deadline = Date.now() + session.expiresInMs;
|
|
85
|
+
const waitSpinner = new Spinner(theme, messages.waiting()).start();
|
|
86
|
+
let lastNudge = Date.now();
|
|
87
|
+
let result = { status: "waiting" };
|
|
88
|
+
while (Date.now() < deadline) {
|
|
89
|
+
await sleep(1500);
|
|
90
|
+
try {
|
|
91
|
+
result = await api("/api/pairing/poll", { body: { code: session.code } });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// A dropped poll is not a failed pairing; keep trying until the code
|
|
95
|
+
// actually expires.
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (result.status !== "waiting")
|
|
99
|
+
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();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
waitSpinner.stop();
|
|
107
|
+
if (result.status === "paired" && result.deviceToken) {
|
|
108
|
+
updateConfig({
|
|
109
|
+
deviceToken: result.deviceToken,
|
|
110
|
+
deviceId: result.device?.id,
|
|
111
|
+
deviceName: result.device?.name ?? deviceName(),
|
|
112
|
+
connectedAt: new Date().toISOString(),
|
|
113
|
+
});
|
|
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
|
+
if (options.listen) {
|
|
132
|
+
const { listenCommand } = await import("./listen.js");
|
|
133
|
+
return listenCommand();
|
|
134
|
+
}
|
|
135
|
+
console.log(` ${style.gray("Run")} ${rgb(theme.accent, "lazy-skill listen")} ${style.gray("to install from your phone.")}`);
|
|
136
|
+
console.log();
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
const reason = result.status === "consumed"
|
|
140
|
+
? "That code was already used."
|
|
141
|
+
: messages.expired();
|
|
142
|
+
console.log(` ${style.yellow("!")} ${reason}`);
|
|
143
|
+
console.log(` ${style.gray("Run")} ${rgb(theme.accent, "npx lazy-skill connect")} ${style.gray("for a fresh one.")}`);
|
|
144
|
+
console.log();
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
function sleep(ms) {
|
|
148
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
149
|
+
}
|