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.
@@ -0,0 +1,70 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { DEFAULT_THEME, isThemeId } from "../ui/theme.js";
5
+ const DIR = join(homedir(), ".lazyskill");
6
+ const FILE = join(DIR, "config.json");
7
+ const DEFAULTS = { theme: DEFAULT_THEME };
8
+ export function configPath() {
9
+ return FILE;
10
+ }
11
+ export function readConfig() {
12
+ try {
13
+ if (!existsSync(FILE))
14
+ return { ...DEFAULTS };
15
+ const parsed = JSON.parse(readFileSync(FILE, "utf8"));
16
+ return {
17
+ ...DEFAULTS,
18
+ ...parsed,
19
+ // Never trust a hand-edited theme value into the rendering path.
20
+ theme: isThemeId(parsed.theme) ? parsed.theme : DEFAULT_THEME,
21
+ };
22
+ }
23
+ catch {
24
+ // A corrupt config should not brick the CLI; fall back to defaults.
25
+ return { ...DEFAULTS };
26
+ }
27
+ }
28
+ export function writeConfig(config) {
29
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
30
+ writeFileSync(FILE, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
31
+ // mkdir/writeFile modes are subject to umask, so set them explicitly.
32
+ try {
33
+ chmodSync(DIR, 0o700);
34
+ chmodSync(FILE, 0o600);
35
+ }
36
+ catch {
37
+ // Windows has no POSIX modes; the file lives in the user profile anyway.
38
+ }
39
+ }
40
+ export function updateConfig(patch) {
41
+ const next = { ...readConfig(), ...patch };
42
+ writeConfig(next);
43
+ return next;
44
+ }
45
+ /** Forgets the device credential but keeps cosmetic preferences. */
46
+ export function clearCredentials() {
47
+ const current = readConfig();
48
+ writeConfig({ theme: current.theme, apiUrl: current.apiUrl });
49
+ }
50
+ export function forgetEverything() {
51
+ try {
52
+ rmSync(FILE, { force: true });
53
+ }
54
+ catch {
55
+ // Nothing to forget.
56
+ }
57
+ }
58
+ /**
59
+ * Where the CLI pairs and polls by default.
60
+ *
61
+ * The deployment's own hostname, not a domain we do not own. A published CLI
62
+ * pointing at an unregistered domain would send every user's pairing attempt
63
+ * nowhere — or, worse, to whoever registers it later. Override with
64
+ * LAZY_SKILL_API_URL when running against a local server.
65
+ */
66
+ export const DEFAULT_API_URL = "https://lazy-skill.vercel.app";
67
+ export function apiBaseUrl() {
68
+ const configured = readConfig().apiUrl ?? process.env.LAZY_SKILL_API_URL;
69
+ return (configured || DEFAULT_API_URL).replace(/\/$/, "");
70
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The device's own copy of the command vocabulary.
3
+ *
4
+ * Duplicated from the server on purpose. A device must not depend on the
5
+ * server for its definition of what is safe to run: if the server is
6
+ * compromised, the only thing standing between it and the user's machine is
7
+ * this file. A test asserts the two copies stay identical.
8
+ */
9
+ export const JOB_COMMANDS = [
10
+ "INSTALL_SKILL",
11
+ "CHECK_STATUS",
12
+ "LIST_SKILLS",
13
+ "DISCONNECT",
14
+ ];
15
+ export const JOB_STAGES = [
16
+ "starting",
17
+ "downloading",
18
+ "installing",
19
+ "verifying",
20
+ "done",
21
+ "failed",
22
+ ];
23
+ export const JOB_AGENTS = ["claude", "codex", "cursor"];
24
+ export const SKILL_REF_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]*(?:\/[A-Za-z0-9_][A-Za-z0-9_.-]*){1,2}$/;
25
+ export const MAX_SKILL_REF_LENGTH = 200;
26
+ export class UnsafeJobError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "UnsafeJobError";
30
+ }
31
+ }
32
+ export function isJobCommand(value) {
33
+ return typeof value === "string" && JOB_COMMANDS.includes(value);
34
+ }
35
+ export function isJobAgent(value) {
36
+ return typeof value === "string" && JOB_AGENTS.includes(value);
37
+ }
38
+ export function isValidSkillRef(value) {
39
+ return (typeof value === "string" &&
40
+ value.length > 0 &&
41
+ value.length <= MAX_SKILL_REF_LENGTH &&
42
+ SKILL_REF_PATTERN.test(value) &&
43
+ !value.includes(".."));
44
+ }
45
+ /**
46
+ * Validates a payload the server sent. Everything the server says is treated
47
+ * as a suggestion until it passes this.
48
+ */
49
+ export function parseInstallPayload(input) {
50
+ const raw = (input ?? {});
51
+ if (!isValidSkillRef(raw.skillRef)) {
52
+ throw new UnsafeJobError("The server sent an invalid skill reference.");
53
+ }
54
+ if (!Array.isArray(raw.agents) || raw.agents.length === 0 || raw.agents.length > 6) {
55
+ throw new UnsafeJobError("The server sent no usable target agents.");
56
+ }
57
+ const agents = [];
58
+ for (const agent of raw.agents) {
59
+ if (!isJobAgent(agent)) {
60
+ throw new UnsafeJobError(`The server sent an unknown agent "${String(agent)}".`);
61
+ }
62
+ if (!agents.includes(agent))
63
+ agents.push(agent);
64
+ }
65
+ const skillName = typeof raw.skillName === "string" && raw.skillName.trim()
66
+ ? raw.skillName.trim().slice(0, 200)
67
+ : raw.skillRef;
68
+ return {
69
+ skillRef: raw.skillRef,
70
+ skillName,
71
+ agents,
72
+ scope: raw.scope === "project" ? "project" : "global",
73
+ };
74
+ }
package/dist/ui/box.js ADDED
@@ -0,0 +1,56 @@
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
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The CLI's voice. Occasional and charming rather than constant (§9) — one
3
+ * joke per stage, not one per line, and never in place of information the
4
+ * user actually needs.
5
+ */
6
+ function pick(list) {
7
+ return list[Math.floor(Math.random() * list.length)];
8
+ }
9
+ export const messages = {
10
+ booting: () => [
11
+ "initializing laziness.exe",
12
+ "searching for human...",
13
+ ],
14
+ humanFound: () => pick([
15
+ "human detected",
16
+ "checking if you're lazy...",
17
+ "found someone. good enough",
18
+ ]),
19
+ generating: () => "generating connection portal...",
20
+ waiting: () => pick([
21
+ "waiting for scan...",
22
+ "still waiting...",
23
+ "please scan before I fall asleep",
24
+ ]),
25
+ scanned: () => "PHONE DETECTED",
26
+ authenticating: () => pick([
27
+ "authenticating...",
28
+ "pretending this is complicated...",
29
+ ]),
30
+ connected: () => "YOU'RE CONNECTED",
31
+ signOff: () => pick([
32
+ "Now go be lazy.",
33
+ "Do less. Install more.",
34
+ "Your computer was doing nothing anyway.",
35
+ ]),
36
+ expired: () => "That QR took too long. It's asleep now.",
37
+ failed: () => "That didn't work. Even Lazy Skill gets tired sometimes.",
38
+ };
package/dist/ui/qr.js ADDED
@@ -0,0 +1,59 @@
1
+ import QRCode from "qrcode";
2
+ /** The terminal renderer paints black modules on a white background. */
3
+ const QR_OPEN = "\x1b[47m\x1b[30m";
4
+ const QR_CLOSE = "\x1b[0m";
5
+ /**
6
+ * Renders the pairing QR.
7
+ *
8
+ * Generated by a real encoder at error-correction level M. Nothing is drawn
9
+ * inside the module area and nothing is substituted for the code — scan
10
+ * reliability is not negotiable for decoration (§10).
11
+ *
12
+ * The quiet zone is added here rather than passed as an option: node-qrcode's
13
+ * terminal renderer ignores `margin` when `small` is set, and ships only a
14
+ * single module of padding. The standard wants four, and without it the finder
15
+ * patterns sit flush against surrounding terminal text, which measurably hurts
16
+ * scanning. Each rendered row is two modules tall (half-blocks), so two blank
17
+ * rows above and below give the four modules vertically, and three extra
18
+ * columns each side top up the one already present.
19
+ */
20
+ export async function renderQr(url) {
21
+ const art = await QRCode.toString(url, {
22
+ type: "terminal",
23
+ small: true,
24
+ errorCorrectionLevel: "M",
25
+ });
26
+ const rows = art
27
+ .split("\n")
28
+ .filter((line) => stripAnsi(line).length > 0)
29
+ // Drop only the wrapper this renderer adds. Interior codes are left alone:
30
+ // the final half-row recolours per cell, and rewriting that would corrupt
31
+ // the modules themselves.
32
+ .map((line) => line.replace(new RegExp(`^${escapeRe(QR_OPEN)}`), "").replace(/\x1b\[0m$/, ""));
33
+ if (rows.length === 0)
34
+ return [];
35
+ const SIDE = 3;
36
+ // Width must be measured in visible cells: the last row carries per-cell
37
+ // escape sequences, and counting those would blow the quiet zone out to
38
+ // hundreds of columns.
39
+ const content = Math.max(...rows.map((row) => visibleLength(row)));
40
+ const width = content + SIDE * 2;
41
+ const blank = `${QR_OPEN}${" ".repeat(width)}${QR_CLOSE}`;
42
+ const padded = rows.map((row) => {
43
+ const trailing = " ".repeat(Math.max(0, content - visibleLength(row)));
44
+ return `${QR_OPEN}${" ".repeat(SIDE)}${row}${trailing}${" ".repeat(SIDE)}${QR_CLOSE}`;
45
+ });
46
+ return [blank, blank, ...padded, blank, blank];
47
+ }
48
+ // eslint-disable-next-line no-control-regex
49
+ const ANSI = /\x1b\[[0-9;]*m/g;
50
+ function stripAnsi(text) {
51
+ return text.replace(ANSI, "");
52
+ }
53
+ /** Visible cell count, ignoring escape sequences. */
54
+ export function visibleLength(text) {
55
+ return stripAnsi(text).length;
56
+ }
57
+ function escapeRe(text) {
58
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
59
+ }
@@ -0,0 +1,42 @@
1
+ import { rgb, hasColor } from "./theme.js";
2
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
+ /**
4
+ * A single-line spinner that cleans up after itself.
5
+ *
6
+ * Falls back to a plain printed line when stdout is not a TTY, so piping the
7
+ * CLI into a log does not produce thousands of redraw sequences.
8
+ */
9
+ export class Spinner {
10
+ theme;
11
+ text;
12
+ timer = null;
13
+ frame = 0;
14
+ constructor(theme, text) {
15
+ this.theme = theme;
16
+ this.text = text;
17
+ }
18
+ start() {
19
+ if (!process.stdout.isTTY || !hasColor) {
20
+ process.stdout.write(` ${this.text}\n`);
21
+ return this;
22
+ }
23
+ process.stdout.write("\x1b[?25l"); // hide cursor
24
+ this.timer = setInterval(() => {
25
+ const glyph = rgb(this.theme.accent, FRAMES[this.frame++ % FRAMES.length]);
26
+ process.stdout.write(`\r ${glyph} ${this.text}\x1b[K`);
27
+ }, 80);
28
+ return this;
29
+ }
30
+ update(text) {
31
+ this.text = text;
32
+ }
33
+ stop(final) {
34
+ if (this.timer) {
35
+ clearInterval(this.timer);
36
+ this.timer = null;
37
+ process.stdout.write("\r\x1b[K\x1b[?25h"); // clear line, show cursor
38
+ }
39
+ if (final)
40
+ process.stdout.write(` ${final}\n`);
41
+ }
42
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * CLI themes. Ids match the web app's exactly, so the theme chosen here can
3
+ * ride along in the pairing payload and the phone adopts the same look
4
+ * (§7/§53). The id is cosmetic and carries no authority.
5
+ */
6
+ export const THEME_IDS = [
7
+ "cyber-purple",
8
+ "cyber-blue",
9
+ "matrix-green",
10
+ "sakura-pink",
11
+ "sunset-orange",
12
+ "teal-mint",
13
+ "monochrome",
14
+ ];
15
+ export const DEFAULT_THEME = "cyber-purple";
16
+ export const THEMES = {
17
+ "cyber-purple": { id: "cyber-purple", label: "Purple Night", accent: [168, 85, 247], support: [94, 234, 212] },
18
+ "cyber-blue": { id: "cyber-blue", label: "Cyber Blue", accent: [56, 189, 248], support: [167, 139, 250] },
19
+ "matrix-green": { id: "matrix-green", label: "Matrix Green", accent: [56, 224, 123], support: [103, 232, 249] },
20
+ "sakura-pink": { id: "sakura-pink", label: "Rose Pink", accent: [244, 114, 182], support: [196, 181, 253] },
21
+ "sunset-orange": { id: "sunset-orange", label: "Sunset Orange", accent: [251, 146, 60], support: [251, 191, 36] },
22
+ "teal-mint": { id: "teal-mint", label: "Teal Mint", accent: [45, 212, 191], support: [167, 139, 250] },
23
+ monochrome: { id: "monochrome", label: "Monochrome", accent: [228, 228, 236], support: [161, 161, 170] },
24
+ };
25
+ export function isThemeId(value) {
26
+ return typeof value === "string" && THEME_IDS.includes(value);
27
+ }
28
+ /**
29
+ * Colour support. NO_COLOR is honoured unconditionally, and a non-TTY (a pipe,
30
+ * a CI log) gets plain text — escape codes in a log file help nobody.
31
+ */
32
+ function colorLevel() {
33
+ if (process.env.NO_COLOR)
34
+ return 0;
35
+ if (process.env.FORCE_COLOR === "0")
36
+ return 0;
37
+ if (process.env.FORCE_COLOR)
38
+ return 2;
39
+ if (!process.stdout.isTTY)
40
+ return 0;
41
+ if (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit")
42
+ return 2;
43
+ if (process.env.TERM === "dumb")
44
+ return 0;
45
+ return 1;
46
+ }
47
+ const LEVEL = colorLevel();
48
+ /** Nearest xterm-256 index, for terminals without truecolor. */
49
+ function to256([r, g, b]) {
50
+ const q = (v) => Math.round((v / 255) * 5);
51
+ return 16 + 36 * q(r) + 6 * q(g) + q(b);
52
+ }
53
+ export function rgb(color, text) {
54
+ if (LEVEL === 0)
55
+ return text;
56
+ if (LEVEL === 1)
57
+ return `\x1b[38;5;${to256(color)}m${text}\x1b[0m`;
58
+ return `\x1b[38;2;${color[0]};${color[1]};${color[2]}m${text}\x1b[0m`;
59
+ }
60
+ export const style = {
61
+ dim: (t) => (LEVEL === 0 ? t : `\x1b[2m${t}\x1b[0m`),
62
+ bold: (t) => (LEVEL === 0 ? t : `\x1b[1m${t}\x1b[0m`),
63
+ green: (t) => (LEVEL === 0 ? t : `\x1b[32m${t}\x1b[0m`),
64
+ yellow: (t) => (LEVEL === 0 ? t : `\x1b[33m${t}\x1b[0m`),
65
+ red: (t) => (LEVEL === 0 ? t : `\x1b[31m${t}\x1b[0m`),
66
+ gray: (t) => (LEVEL === 0 ? t : `\x1b[90m${t}\x1b[0m`),
67
+ };
68
+ export const hasColor = LEVEL > 0;
69
+ /** Visible width, ignoring escape codes, so box borders line up. */
70
+ export function visibleWidth(text) {
71
+ // eslint-disable-next-line no-control-regex
72
+ return text.replace(/\x1b\[[0-9;]*m/g, "").length;
73
+ }
package/package.json CHANGED
@@ -1,20 +1,59 @@
1
1
  {
2
2
  "name": "lazy-skill",
3
- "version": "0.0.1",
4
- "description": "Lazy Skill — see it, search it, install it. Not released yet.",
5
- "license": "MIT",
6
- "author": "ketchupketchup",
3
+ "version": "0.1.0",
4
+ "description": "Found an AI skill while scrolling? Install it to your computer from your phone.",
5
+ "type": "module",
6
+ "bin": {
7
+ "lazy-skill": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json",
19
+ "dev": "tsc -p tsconfig.json --watch",
20
+ "start": "node dist/index.js",
21
+ "typecheck": "tsc -p tsconfig.json --noEmit",
22
+ "test": "node test/qr.test.mjs && node test/install-plan.test.mjs && node test/stage.test.mjs",
23
+ "pretest": "tsc -p tsconfig.json",
24
+ "prepublishOnly": "rm -rf dist && tsc -p tsconfig.json"
25
+ },
7
26
  "keywords": [
8
27
  "ai",
9
28
  "skills",
29
+ "agent-skills",
10
30
  "claude",
31
+ "claude-code",
11
32
  "codex",
12
33
  "cursor",
13
- "cli"
14
- ],
15
- "files": [
16
- "README.md"
34
+ "cli",
35
+ "qr",
36
+ "developer-tools"
17
37
  ],
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "qrcode": "^1.5.4"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20",
44
+ "@types/qrcode": "^1.5.5",
45
+ "typescript": "^5"
46
+ },
47
+ "homepage": "https://github.com/kashhhyuppp/lazy-skill#readme",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/kashhhyuppp/lazy-skill.git",
51
+ "directory": "cli"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/kashhhyuppp/lazy-skill/issues"
55
+ },
56
+ "author": "kashhhyuppp",
18
57
  "publishConfig": {
19
58
  "access": "public"
20
59
  }