@dhruvwill/skills-cli 1.0.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,84 @@
1
+ import chalk from "chalk";
2
+ import Table from "cli-table3";
3
+ import { getSources, getTargets } from "../lib/config.ts";
4
+ import { SKILLS_ROOT, SKILLS_STORE, getNamespacePath } from "../lib/paths.ts";
5
+ import { directoryExists, compareDirectories } from "../lib/hash.ts";
6
+
7
+ /**
8
+ * Show status overview of sources, targets, and sync state
9
+ */
10
+ export async function status(): Promise<void> {
11
+ console.log();
12
+ console.log(chalk.bold("Skills Status"));
13
+ console.log(chalk.dim("─".repeat(50)));
14
+
15
+ // Paths
16
+ console.log();
17
+ console.log(chalk.bold("Paths"));
18
+ console.log(` Root: ${chalk.cyan(SKILLS_ROOT)}`);
19
+ console.log(` Store: ${chalk.cyan(SKILLS_STORE)}`);
20
+
21
+ // Sources summary
22
+ const sources = await getSources();
23
+ console.log();
24
+ console.log(chalk.bold(`Sources (${sources.length})`));
25
+
26
+ if (sources.length === 0) {
27
+ console.log(chalk.dim(" No sources registered"));
28
+ } else {
29
+ for (const source of sources) {
30
+ const exists = await directoryExists(getNamespacePath(source.namespace));
31
+ const icon = exists ? chalk.green("●") : chalk.red("●");
32
+ const typeLabel = source.type === "remote" ? chalk.blue("remote") : chalk.magenta("local");
33
+ console.log(` ${icon} ${source.namespace} ${chalk.dim(`(${typeLabel})`)}`);
34
+ }
35
+ }
36
+
37
+ // Targets summary
38
+ const targets = await getTargets();
39
+ console.log();
40
+ console.log(chalk.bold(`Targets (${targets.length})`));
41
+
42
+ if (targets.length === 0) {
43
+ console.log(chalk.dim(" No targets registered"));
44
+ } else {
45
+ for (const target of targets) {
46
+ const syncStatus = await compareDirectories(SKILLS_STORE, target.path);
47
+ let icon: string;
48
+ let statusText: string;
49
+
50
+ if (syncStatus === "synced") {
51
+ icon = chalk.green("●");
52
+ statusText = chalk.green("synced");
53
+ } else if (syncStatus === "not synced") {
54
+ icon = chalk.yellow("●");
55
+ statusText = chalk.yellow("outdated");
56
+ } else {
57
+ icon = chalk.red("●");
58
+ statusText = chalk.red("missing");
59
+ }
60
+
61
+ console.log(` ${icon} ${target.name} ${chalk.dim(`→ ${statusText}`)}`);
62
+ }
63
+ }
64
+
65
+ // Quick actions
66
+ console.log();
67
+ console.log(chalk.bold("Quick Actions"));
68
+
69
+ if (sources.length === 0) {
70
+ console.log(` ${chalk.cyan("skills source add <url> --remote")} Add a remote skill`);
71
+ }
72
+
73
+ if (targets.length === 0) {
74
+ console.log(` ${chalk.cyan("skills target add <name> <path>")} Add a target`);
75
+ }
76
+
77
+ if (sources.length > 0 && targets.length > 0) {
78
+ console.log(` ${chalk.cyan("skills sync")} Push skills to all targets`);
79
+ console.log(` ${chalk.cyan("skills update")} Refresh all sources`);
80
+ }
81
+
82
+ console.log(` ${chalk.cyan("skills doctor")} Run diagnostics`);
83
+ console.log();
84
+ }
@@ -0,0 +1,72 @@
1
+ import { cp, rm, mkdir } from "fs/promises";
2
+ import { getTargets } from "../lib/config.ts";
3
+ import { SKILLS_STORE } from "../lib/paths.ts";
4
+ import { directoryExists } from "../lib/hash.ts";
5
+ import type { Target } from "../types.ts";
6
+
7
+ /**
8
+ * Sync a single target with the store
9
+ */
10
+ export async function syncToTarget(target: Target): Promise<void> {
11
+ // Ensure target directory exists
12
+ await mkdir(target.path, { recursive: true });
13
+
14
+ // Check if store has content
15
+ const storeExists = await directoryExists(SKILLS_STORE);
16
+ if (!storeExists) {
17
+ console.log(` ${target.name}: Store is empty, nothing to sync`);
18
+ return;
19
+ }
20
+
21
+ // Clear target and copy fresh from store
22
+ // We remove contents but keep the directory
23
+ const entries = await Bun.file(target.path).exists() ? [] : [];
24
+
25
+ try {
26
+ // Remove existing contents
27
+ const { readdir } = await import("fs/promises");
28
+ const existingFiles = await readdir(target.path);
29
+
30
+ for (const file of existingFiles) {
31
+ await rm(`${target.path}/${file}`, { recursive: true, force: true });
32
+ }
33
+ } catch {
34
+ // Directory might be empty or not exist
35
+ }
36
+
37
+ // Copy store contents to target
38
+ await cp(SKILLS_STORE, target.path, { recursive: true });
39
+
40
+ console.log(` ${target.name}: Synced`);
41
+ }
42
+
43
+ /**
44
+ * Sync all targets with the store
45
+ */
46
+ export async function sync(): Promise<void> {
47
+ const targets = await getTargets();
48
+
49
+ if (targets.length === 0) {
50
+ console.log("No targets registered.");
51
+ console.log("Add a target with: skills target add <name> <path>");
52
+ return;
53
+ }
54
+
55
+ const storeExists = await directoryExists(SKILLS_STORE);
56
+ if (!storeExists) {
57
+ console.log("Store is empty. Add sources first with: skills source add <url> --remote");
58
+ return;
59
+ }
60
+
61
+ console.log("Syncing skills to all targets...\n");
62
+
63
+ for (const target of targets) {
64
+ try {
65
+ await syncToTarget(target);
66
+ } catch (error) {
67
+ console.error(` ${target.name}: Failed - ${error instanceof Error ? error.message : error}`);
68
+ }
69
+ }
70
+
71
+ console.log("\nSync complete.");
72
+ }
@@ -0,0 +1,97 @@
1
+ import { resolve } from "path";
2
+ import { mkdir } from "fs/promises";
3
+ import chalk from "chalk";
4
+ import Table from "cli-table3";
5
+ import { addTarget, removeTarget, getTargets } from "../lib/config.ts";
6
+ import { SKILLS_STORE } from "../lib/paths.ts";
7
+ import { compareDirectories, directoryExists } from "../lib/hash.ts";
8
+ import { syncToTarget } from "./sync.ts";
9
+
10
+ /**
11
+ * Add a target directory
12
+ */
13
+ export async function targetAdd(name: string, path: string): Promise<void> {
14
+ const absolutePath = resolve(path.replace(/^~/, process.env.HOME || process.env.USERPROFILE || ""));
15
+
16
+ console.log(`Adding target: ${name}`);
17
+ console.log(`Path: ${absolutePath}`);
18
+
19
+ // Create the target directory if it doesn't exist
20
+ await mkdir(absolutePath, { recursive: true });
21
+
22
+ // Add to config
23
+ await addTarget({
24
+ name,
25
+ path: absolutePath,
26
+ });
27
+
28
+ console.log(`Successfully registered target: ${name}`);
29
+
30
+ // Perform initial sync
31
+ console.log("Performing initial sync...");
32
+ await syncToTarget({ name, path: absolutePath });
33
+
34
+ console.log(`Target "${name}" is now synced.`);
35
+ }
36
+
37
+ /**
38
+ * Remove a target by name
39
+ */
40
+ export async function targetRemove(name: string): Promise<void> {
41
+ const removed = await removeTarget(name);
42
+
43
+ if (!removed) {
44
+ throw new Error(`Target not found: ${name}`);
45
+ }
46
+
47
+ console.log(`Removed target: ${name}`);
48
+ console.log(`Note: Files at ${removed.path} were not deleted.`);
49
+ }
50
+
51
+ /**
52
+ * List all targets with sync status in a table format
53
+ */
54
+ export async function targetList(): Promise<void> {
55
+ const targets = await getTargets();
56
+
57
+ if (targets.length === 0) {
58
+ console.log("No targets registered.");
59
+ console.log(`Add a target with: ${chalk.cyan("skills target add <name> <path>")}`);
60
+ return;
61
+ }
62
+
63
+ const table = new Table({
64
+ head: [
65
+ chalk.bold("Name"),
66
+ chalk.bold("Path"),
67
+ chalk.bold("Status"),
68
+ ],
69
+ style: {
70
+ head: [],
71
+ border: [],
72
+ },
73
+ });
74
+
75
+ for (const target of targets) {
76
+ const status = await compareDirectories(SKILLS_STORE, target.path);
77
+ let statusDisplay: string;
78
+
79
+ if (status === "synced") {
80
+ statusDisplay = chalk.green("synced");
81
+ } else if (status === "not synced") {
82
+ statusDisplay = chalk.yellow("outdated");
83
+ } else {
84
+ statusDisplay = chalk.red("missing");
85
+ }
86
+
87
+ table.push([
88
+ target.name,
89
+ target.path,
90
+ statusDisplay,
91
+ ]);
92
+ }
93
+
94
+ console.log();
95
+ console.log(table.toString());
96
+ console.log();
97
+ }
@@ -0,0 +1,108 @@
1
+ import { $ } from "bun";
2
+ import { rm, cp } from "fs/promises";
3
+ import { getSources } from "../lib/config.ts";
4
+ import { getNamespacePath, parseGitUrl } from "../lib/paths.ts";
5
+ import { directoryExists } from "../lib/hash.ts";
6
+
7
+ /**
8
+ * Update all sources from their origins
9
+ */
10
+ export async function update(): Promise<void> {
11
+ const sources = await getSources();
12
+
13
+ if (sources.length === 0) {
14
+ console.log("No sources registered.");
15
+ console.log("Add a source with: skills source add <url> --remote");
16
+ return;
17
+ }
18
+
19
+ console.log("Updating all sources...\n");
20
+
21
+ for (const source of sources) {
22
+ try {
23
+ if (source.type === "remote") {
24
+ await updateRemoteSource(source.namespace, source.url!);
25
+ } else {
26
+ await updateLocalSource(source.namespace, source.path!);
27
+ }
28
+ } catch (error) {
29
+ console.error(` ${source.namespace}: Failed - ${error instanceof Error ? error.message : error}`);
30
+ }
31
+ }
32
+
33
+ console.log("\nUpdate complete.");
34
+ console.log("Run 'skills sync' to push changes to targets.");
35
+ }
36
+
37
+ /**
38
+ * Update a remote source by re-cloning
39
+ */
40
+ async function updateRemoteSource(namespace: string, url: string): Promise<void> {
41
+ const targetPath = getNamespacePath(namespace);
42
+ const parsed = parseGitUrl(url);
43
+
44
+ if (!parsed) {
45
+ throw new Error(`Invalid URL: ${url}`);
46
+ }
47
+
48
+ const cloneUrl = parsed.cloneUrl;
49
+ const branch = parsed.branch || "main";
50
+
51
+ console.log(` ${namespace}: Updating from ${url}...`);
52
+
53
+ // Remove existing directory
54
+ await rm(targetPath, { recursive: true, force: true });
55
+
56
+ // Re-clone
57
+ try {
58
+ if (parsed.subdir) {
59
+ // Use sparse checkout for subdirectory
60
+ const tempPath = `${targetPath}_temp`;
61
+
62
+ await $`git clone --filter=blob:none --no-checkout --depth 1 --branch ${branch} ${cloneUrl} ${tempPath}`.quiet();
63
+ await $`git -C ${tempPath} sparse-checkout init --cone`.quiet();
64
+ await $`git -C ${tempPath} sparse-checkout set ${parsed.subdir}`.quiet();
65
+ await $`git -C ${tempPath} checkout`.quiet();
66
+
67
+ // Move subdirectory to target
68
+ const subdirFullPath = `${tempPath}/${parsed.subdir}`;
69
+ await cp(subdirFullPath, targetPath, { recursive: true });
70
+
71
+ // Clean up
72
+ await rm(tempPath, { recursive: true, force: true });
73
+ } else {
74
+ await $`git clone --depth 1 --branch ${branch} ${cloneUrl} ${targetPath}`.quiet();
75
+
76
+ // Remove .git directory
77
+ await rm(`${targetPath}/.git`, { recursive: true, force: true });
78
+ }
79
+
80
+ console.log(` ${namespace}: Updated`);
81
+ } catch (error) {
82
+ // Clean up on failure
83
+ await rm(`${targetPath}_temp`, { recursive: true, force: true });
84
+ throw new Error(`Failed to clone: ${error}`);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Update a local source by re-copying
90
+ */
91
+ async function updateLocalSource(namespace: string, sourcePath: string): Promise<void> {
92
+ const targetPath = getNamespacePath(namespace);
93
+
94
+ console.log(` ${namespace}: Updating from ${sourcePath}...`);
95
+
96
+ // Check if source still exists
97
+ if (!(await directoryExists(sourcePath))) {
98
+ throw new Error(`Source folder no longer exists: ${sourcePath}`);
99
+ }
100
+
101
+ // Remove existing directory
102
+ await rm(targetPath, { recursive: true, force: true });
103
+
104
+ // Re-copy
105
+ await cp(sourcePath, targetPath, { recursive: true });
106
+
107
+ console.log(` ${namespace}: Updated`);
108
+ }
@@ -0,0 +1,127 @@
1
+ import { mkdir } from "fs/promises";
2
+ import { SKILLS_ROOT, SKILLS_STORE, CONFIG_PATH } from "./paths.ts";
3
+ import type { Config, Source, Target } from "../types.ts";
4
+
5
+ const DEFAULT_CONFIG: Config = {
6
+ sources: [],
7
+ targets: [],
8
+ };
9
+
10
+ /**
11
+ * Ensure the skills directories exist
12
+ */
13
+ export async function ensureDirectories(): Promise<void> {
14
+ await mkdir(SKILLS_ROOT, { recursive: true });
15
+ await mkdir(SKILLS_STORE, { recursive: true });
16
+ }
17
+
18
+ /**
19
+ * Read the config file, creating it if it doesn't exist
20
+ */
21
+ export async function readConfig(): Promise<Config> {
22
+ await ensureDirectories();
23
+
24
+ const configFile = Bun.file(CONFIG_PATH);
25
+
26
+ if (!(await configFile.exists())) {
27
+ await writeConfig(DEFAULT_CONFIG);
28
+ return DEFAULT_CONFIG;
29
+ }
30
+
31
+ try {
32
+ const content = await configFile.json();
33
+ return content as Config;
34
+ } catch {
35
+ // If config is corrupted, reset it
36
+ await writeConfig(DEFAULT_CONFIG);
37
+ return DEFAULT_CONFIG;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Write the config file
43
+ */
44
+ export async function writeConfig(config: Config): Promise<void> {
45
+ await ensureDirectories();
46
+ await Bun.write(CONFIG_PATH, JSON.stringify(config, null, 2));
47
+ }
48
+
49
+ /**
50
+ * Add a source to the config
51
+ */
52
+ export async function addSource(source: Source): Promise<void> {
53
+ const config = await readConfig();
54
+
55
+ // Check if namespace already exists
56
+ const existing = config.sources.find(s => s.namespace === source.namespace);
57
+ if (existing) {
58
+ throw new Error(`Source with namespace "${source.namespace}" already exists`);
59
+ }
60
+
61
+ config.sources.push(source);
62
+ await writeConfig(config);
63
+ }
64
+
65
+ /**
66
+ * Remove a source from the config
67
+ */
68
+ export async function removeSource(namespace: string): Promise<Source | null> {
69
+ const config = await readConfig();
70
+
71
+ const index = config.sources.findIndex(s => s.namespace === namespace);
72
+ if (index === -1) {
73
+ return null;
74
+ }
75
+
76
+ const [removed] = config.sources.splice(index, 1);
77
+ await writeConfig(config);
78
+ return removed;
79
+ }
80
+
81
+ /**
82
+ * Get all sources
83
+ */
84
+ export async function getSources(): Promise<Source[]> {
85
+ const config = await readConfig();
86
+ return config.sources;
87
+ }
88
+
89
+ /**
90
+ * Add a target to the config
91
+ */
92
+ export async function addTarget(target: Target): Promise<void> {
93
+ const config = await readConfig();
94
+
95
+ // Check if name already exists
96
+ const existing = config.targets.find(t => t.name === target.name);
97
+ if (existing) {
98
+ throw new Error(`Target with name "${target.name}" already exists`);
99
+ }
100
+
101
+ config.targets.push(target);
102
+ await writeConfig(config);
103
+ }
104
+
105
+ /**
106
+ * Remove a target from the config
107
+ */
108
+ export async function removeTarget(name: string): Promise<Target | null> {
109
+ const config = await readConfig();
110
+
111
+ const index = config.targets.findIndex(t => t.name === name);
112
+ if (index === -1) {
113
+ return null;
114
+ }
115
+
116
+ const [removed] = config.targets.splice(index, 1);
117
+ await writeConfig(config);
118
+ return removed;
119
+ }
120
+
121
+ /**
122
+ * Get all targets
123
+ */
124
+ export async function getTargets(): Promise<Target[]> {
125
+ const config = await readConfig();
126
+ return config.targets;
127
+ }
@@ -0,0 +1,80 @@
1
+ import { readdir, stat } from "fs/promises";
2
+ import { join } from "path";
3
+
4
+ /**
5
+ * Recursively get all files in a directory with their content hashes
6
+ */
7
+ async function getDirectoryFiles(dirPath: string, basePath: string = ""): Promise<Map<string, string>> {
8
+ const files = new Map<string, string>();
9
+
10
+ try {
11
+ const entries = await readdir(dirPath, { withFileTypes: true });
12
+
13
+ for (const entry of entries) {
14
+ const fullPath = join(dirPath, entry.name);
15
+ const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
16
+
17
+ if (entry.isDirectory()) {
18
+ const subFiles = await getDirectoryFiles(fullPath, relativePath);
19
+ for (const [path, hash] of subFiles) {
20
+ files.set(path, hash);
21
+ }
22
+ } else if (entry.isFile()) {
23
+ const file = Bun.file(fullPath);
24
+ const content = await file.arrayBuffer();
25
+ const hash = Bun.hash(content).toString(16);
26
+ files.set(relativePath, hash);
27
+ }
28
+ }
29
+ } catch {
30
+ // Directory doesn't exist or can't be read
31
+ }
32
+
33
+ return files;
34
+ }
35
+
36
+ /**
37
+ * Compute a hash representing the entire directory structure and contents
38
+ */
39
+ export async function hashDirectory(dirPath: string): Promise<string> {
40
+ const files = await getDirectoryFiles(dirPath);
41
+
42
+ if (files.size === 0) {
43
+ return "empty";
44
+ }
45
+
46
+ // Sort files by path for consistent hashing
47
+ const sortedEntries = Array.from(files.entries()).sort((a, b) => a[0].localeCompare(b[0]));
48
+
49
+ // Combine all file paths and hashes
50
+ const combined = sortedEntries.map(([path, hash]) => `${path}:${hash}`).join("|");
51
+
52
+ return Bun.hash(combined).toString(16);
53
+ }
54
+
55
+ /**
56
+ * Check if a directory exists and is not empty
57
+ */
58
+ export async function directoryExists(dirPath: string): Promise<boolean> {
59
+ try {
60
+ const stats = await stat(dirPath);
61
+ return stats.isDirectory();
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Compare two directories and return if they are in sync
69
+ */
70
+ export async function compareDirectories(source: string, target: string): Promise<"synced" | "not synced" | "target missing"> {
71
+ const targetExists = await directoryExists(target);
72
+ if (!targetExists) {
73
+ return "target missing";
74
+ }
75
+
76
+ const sourceHash = await hashDirectory(source);
77
+ const targetHash = await hashDirectory(target);
78
+
79
+ return sourceHash === targetHash ? "synced" : "not synced";
80
+ }