claude-playbook 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/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # claude-playbook CLI
2
+
3
+ Discover and install Claude Code skills, agents, and MCPs from your terminal.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npx claude-playbook <command>
9
+
10
+ # Or install globally
11
+ npm install -g claude-playbook
12
+ ```
13
+
14
+ ## Commands
15
+
16
+ ### Search
17
+
18
+ Search for skills, agents, or MCPs:
19
+
20
+ ```bash
21
+ # Search everything
22
+ claude-playbook search git
23
+
24
+ # Search specific type
25
+ claude-playbook search database --type mcp
26
+ ```
27
+
28
+ ### List
29
+
30
+ List all available items:
31
+
32
+ ```bash
33
+ # List everything
34
+ claude-playbook list
35
+
36
+ # List specific type
37
+ claude-playbook list skills
38
+ claude-playbook list agents
39
+ claude-playbook list mcps
40
+
41
+ # Filter by category
42
+ claude-playbook list --category "code-review"
43
+ ```
44
+
45
+ ### Install
46
+
47
+ Install an MCP to your Claude Code configuration:
48
+
49
+ ```bash
50
+ # Install an MCP
51
+ claude-playbook install github
52
+ claude-playbook install stripe
53
+
54
+ # Preview changes without installing
55
+ claude-playbook install firebase --dry-run
56
+
57
+ # Check skill usage (skills are built-in)
58
+ claude-playbook install commit --type skill
59
+ ```
60
+
61
+ ### Sync
62
+
63
+ Sync your Playbook between web and local Claude Code:
64
+
65
+ ```bash
66
+ # Full bidirectional sync (pull items, push installation status)
67
+ claude-playbook sync
68
+
69
+ # Pull only (don't update web badges)
70
+ claude-playbook sync --no-push
71
+
72
+ # Preview changes without making them
73
+ claude-playbook sync --dry-run
74
+
75
+ # Skip all confirmations
76
+ claude-playbook sync --force
77
+ ```
78
+
79
+ **Authentication Options:**
80
+
81
+ 1. **Session Token** (short-lived): Get from `/auth/cli` page on web app
82
+ 2. **Personal Access Token** (recommended): Create in Settings > Access Tokens
83
+
84
+ ```bash
85
+ # Using PAT (recommended for automation)
86
+ claude-playbook sync --token pat_abc12345_abcdef1234567890...
87
+
88
+ # Using environment variable
89
+ export PLAYBOOK_SUPABASE_URL=https://your-project.supabase.co
90
+ claude-playbook sync
91
+ ```
92
+
93
+ ### Config
94
+
95
+ View or manage your configuration:
96
+
97
+ ```bash
98
+ # Show current config
99
+ claude-playbook config --show
100
+
101
+ # Show config file path
102
+ claude-playbook config --path
103
+
104
+ # Reset configuration
105
+ claude-playbook config --reset
106
+ ```
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ cd packages/cli
112
+ npm install
113
+ npm run build
114
+ node dist/index.js search git
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const configCommand: Command;
@@ -0,0 +1,64 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import * as os from "os";
6
+ const CLAUDE_CONFIG_DIR = path.join(os.homedir(), ".claude");
7
+ const SETTINGS_FILE = path.join(CLAUDE_CONFIG_DIR, "settings.json");
8
+ function readSettings() {
9
+ if (!fs.existsSync(SETTINGS_FILE)) {
10
+ return {};
11
+ }
12
+ const content = fs.readFileSync(SETTINGS_FILE, "utf-8");
13
+ return JSON.parse(content);
14
+ }
15
+ export const configCommand = new Command("config")
16
+ .description("View or modify Claude Code configuration")
17
+ .option("--show", "Show current configuration")
18
+ .option("--path", "Show config file path")
19
+ .option("--reset", "Reset configuration to defaults")
20
+ .action((options) => {
21
+ if (options.path) {
22
+ console.log(SETTINGS_FILE);
23
+ return;
24
+ }
25
+ if (options.reset) {
26
+ if (fs.existsSync(SETTINGS_FILE)) {
27
+ const backupPath = SETTINGS_FILE + ".backup";
28
+ fs.copyFileSync(SETTINGS_FILE, backupPath);
29
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify({}, null, 2));
30
+ console.log(chalk.green("Configuration reset"));
31
+ console.log(chalk.gray(`Backup saved to: ${backupPath}`));
32
+ }
33
+ else {
34
+ console.log(chalk.yellow("No configuration file exists yet"));
35
+ }
36
+ return;
37
+ }
38
+ // Default: show config
39
+ const settings = readSettings();
40
+ if (Object.keys(settings).length === 0) {
41
+ console.log(chalk.yellow("No configuration found"));
42
+ console.log(chalk.gray(`\nConfig path: ${SETTINGS_FILE}`));
43
+ console.log(chalk.gray("Use 'claude-playbook install <mcp>' to add MCP servers"));
44
+ return;
45
+ }
46
+ console.log(chalk.bold("\nClaude Code Configuration:\n"));
47
+ if (settings.mcpServers) {
48
+ console.log(chalk.cyan("MCP Servers:"));
49
+ for (const [name, config] of Object.entries(settings.mcpServers)) {
50
+ console.log(` ${chalk.green("●")} ${name}`);
51
+ if (Object.keys(config).length > 0) {
52
+ console.log(chalk.gray(` ${JSON.stringify(config)}`));
53
+ }
54
+ }
55
+ console.log();
56
+ }
57
+ for (const [key, value] of Object.entries(settings)) {
58
+ if (key !== "mcpServers") {
59
+ console.log(chalk.cyan(`${key}:`));
60
+ console.log(chalk.gray(` ${JSON.stringify(value)}`));
61
+ }
62
+ }
63
+ console.log(chalk.gray(`\nConfig path: ${SETTINGS_FILE}`));
64
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const installCommand: Command;
@@ -0,0 +1,82 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+ import { fetchMCPs, fetchSkills } from "../registry.js";
8
+ const CLAUDE_CONFIG_DIR = path.join(os.homedir(), ".claude");
9
+ const SETTINGS_FILE = path.join(CLAUDE_CONFIG_DIR, "settings.json");
10
+ function readSettings() {
11
+ if (!fs.existsSync(SETTINGS_FILE)) {
12
+ return {};
13
+ }
14
+ const content = fs.readFileSync(SETTINGS_FILE, "utf-8");
15
+ return JSON.parse(content);
16
+ }
17
+ function writeSettings(settings) {
18
+ if (!fs.existsSync(CLAUDE_CONFIG_DIR)) {
19
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
20
+ }
21
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
22
+ }
23
+ export const installCommand = new Command("install")
24
+ .description("Install a skill or MCP to your Claude Code configuration")
25
+ .argument("<name>", "Name of the skill or MCP to install")
26
+ .option("-t, --type <type>", "Type to install (skill, mcp)", "mcp")
27
+ .option("--dry-run", "Show what would be installed without making changes")
28
+ .action(async (name, options) => {
29
+ const spinner = ora(`Looking for ${name}...`).start();
30
+ try {
31
+ if (options.type === "mcp") {
32
+ const mcps = await fetchMCPs();
33
+ const mcp = mcps.find((m) => m.id.toLowerCase() === name.toLowerCase() || m.name.toLowerCase() === name.toLowerCase());
34
+ if (!mcp) {
35
+ spinner.fail(`MCP "${name}" not found`);
36
+ console.log(chalk.gray("\nUse 'claude-playbook search <query>' to find available MCPs"));
37
+ process.exit(1);
38
+ }
39
+ spinner.text = `Installing ${mcp.name}...`;
40
+ const config = { [mcp.id]: {} };
41
+ if (options.dryRun) {
42
+ spinner.info("Dry run - would add to settings.json:");
43
+ console.log(chalk.cyan(JSON.stringify({ mcpServers: config }, null, 2)));
44
+ return;
45
+ }
46
+ const settings = readSettings();
47
+ settings.mcpServers = {
48
+ ...settings.mcpServers,
49
+ ...config,
50
+ };
51
+ writeSettings(settings);
52
+ spinner.succeed(`Installed ${chalk.bold(mcp.name)}`);
53
+ console.log(chalk.gray(`\n Added to ${SETTINGS_FILE}`));
54
+ console.log(chalk.gray(` Tools: ${mcp.tools.join(", ")}`));
55
+ if (mcp.documentationUrl) {
56
+ console.log(chalk.gray(` Docs: ${mcp.documentationUrl}`));
57
+ }
58
+ }
59
+ else if (options.type === "skill") {
60
+ const skills = await fetchSkills();
61
+ const skill = skills.find((s) => s.id.toLowerCase() === name.toLowerCase() || s.name.toLowerCase() === name.toLowerCase());
62
+ if (!skill) {
63
+ spinner.fail(`Skill "${name}" not found`);
64
+ process.exit(1);
65
+ }
66
+ spinner.succeed(`Found skill: ${chalk.bold(skill.name)}`);
67
+ console.log(chalk.gray(`\n Invocation: ${skill.invocation}`));
68
+ console.log(chalk.gray(` ${skill.description}`));
69
+ console.log(chalk.yellow("\n Note: Skills are built-in and don't require installation."));
70
+ console.log(chalk.yellow(` Just use: ${skill.invocation}`));
71
+ }
72
+ else {
73
+ spinner.fail(`Unknown type: ${options.type}`);
74
+ process.exit(1);
75
+ }
76
+ }
77
+ catch (error) {
78
+ spinner.fail("Installation failed");
79
+ console.error(chalk.red(error.message));
80
+ process.exit(1);
81
+ }
82
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const listCommand: Command;
@@ -0,0 +1,72 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { fetchSkills, fetchAgents, fetchMCPs } from "../registry.js";
5
+ export const listCommand = new Command("list")
6
+ .description("List all available skills, agents, or MCPs")
7
+ .argument("[type]", "Type to list (skill, agent, mcp, or all)", "all")
8
+ .option("-c, --category <category>", "Filter by category")
9
+ .action(async (type, options) => {
10
+ const spinner = ora("Fetching from registry...").start();
11
+ try {
12
+ const showType = (t) => type === "all" || type === t || type === t + "s";
13
+ if (showType("skill")) {
14
+ try {
15
+ const skills = await fetchSkills();
16
+ spinner.stop();
17
+ const filtered = options.category
18
+ ? skills.filter((s) => s.category?.toLowerCase() === options.category?.toLowerCase())
19
+ : skills;
20
+ console.log(chalk.bold.magenta(`\nSkills (${filtered.length}):\n`));
21
+ for (const skill of filtered) {
22
+ console.log(` ${chalk.magenta("/")}${skill.name}`);
23
+ console.log(chalk.gray(` ${skill.description}`));
24
+ }
25
+ }
26
+ catch {
27
+ console.log(chalk.yellow("\nNo skills found in registry"));
28
+ }
29
+ }
30
+ if (showType("agent")) {
31
+ try {
32
+ const agents = await fetchAgents();
33
+ spinner.stop();
34
+ const filtered = options.category
35
+ ? agents.filter((a) => a.category?.toLowerCase() === options.category?.toLowerCase())
36
+ : agents;
37
+ console.log(chalk.bold.blue(`\nAgents (${filtered.length}):\n`));
38
+ for (const agent of filtered) {
39
+ console.log(` ${chalk.blue("●")} ${agent.name}`);
40
+ console.log(chalk.gray(` ${agent.description}`));
41
+ }
42
+ }
43
+ catch {
44
+ console.log(chalk.yellow("\nNo agents found in registry"));
45
+ }
46
+ }
47
+ if (showType("mcp")) {
48
+ try {
49
+ const mcps = await fetchMCPs();
50
+ spinner.stop();
51
+ const filtered = options.category
52
+ ? mcps.filter((m) => m.category?.toLowerCase() === options.category?.toLowerCase())
53
+ : mcps;
54
+ console.log(chalk.bold.green(`\nMCP Servers (${filtered.length}):\n`));
55
+ for (const mcp of filtered) {
56
+ console.log(` ${chalk.green("⚡")} ${mcp.name} (${mcp.toolCount} tools)`);
57
+ console.log(chalk.gray(` ${mcp.description}`));
58
+ }
59
+ }
60
+ catch {
61
+ console.log(chalk.yellow("\nNo MCPs found in registry"));
62
+ }
63
+ }
64
+ spinner.stop();
65
+ console.log();
66
+ }
67
+ catch (error) {
68
+ spinner.fail("Failed to fetch registry");
69
+ console.error(chalk.red(error.message));
70
+ process.exit(1);
71
+ }
72
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const searchCommand: Command;
@@ -0,0 +1,34 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { searchRegistry } from "../registry.js";
5
+ export const searchCommand = new Command("search")
6
+ .description("Search for skills, agents, or MCPs")
7
+ .argument("<query>", "Search query")
8
+ .option("-t, --type <type>", "Filter by type (skill, agent, mcp)")
9
+ .action(async (query, options) => {
10
+ const spinner = ora("Searching registry...").start();
11
+ try {
12
+ const results = await searchRegistry(query, options.type);
13
+ spinner.stop();
14
+ if (results.length === 0) {
15
+ console.log(chalk.yellow(`No results found for "${query}"`));
16
+ return;
17
+ }
18
+ console.log(chalk.bold(`\nFound ${results.length} result(s):\n`));
19
+ for (const { type, item } of results) {
20
+ const typeColor = type === "skill" ? chalk.magenta : type === "agent" ? chalk.blue : chalk.green;
21
+ console.log(typeColor(`[${type.toUpperCase()}]`) + " " + chalk.bold(item.name));
22
+ console.log(chalk.gray(` ${item.description}`));
23
+ if (item.category) {
24
+ console.log(chalk.gray(` Category: ${item.category}`));
25
+ }
26
+ console.log();
27
+ }
28
+ }
29
+ catch (error) {
30
+ spinner.fail("Failed to search registry");
31
+ console.error(chalk.red(error.message));
32
+ process.exit(1);
33
+ }
34
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const syncCommand: Command;
@@ -0,0 +1,568 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+ import * as readline from "readline";
8
+ import { spawnSync } from "child_process";
9
+ import lockfile from "proper-lockfile";
10
+ import { getSafeInstalledItems } from "../config-reader.js";
11
+ const CLAUDE_CONFIG_DIR = path.join(os.homedir(), ".claude");
12
+ const SETTINGS_FILE = path.join(CLAUDE_CONFIG_DIR, "settings.json");
13
+ const SYNC_META_FILE = path.join(CLAUDE_CONFIG_DIR, ".playbook-sync.json");
14
+ const BACKUP_DIR = path.join(CLAUDE_CONFIG_DIR, ".playbook-backups");
15
+ const LOCK_OPTIONS = {
16
+ stale: 30000, // Consider lock stale after 30 seconds
17
+ retries: {
18
+ retries: 3,
19
+ minTimeout: 1000,
20
+ maxTimeout: 3000,
21
+ },
22
+ };
23
+ // Acquire exclusive lock for sync operations
24
+ async function acquireLock() {
25
+ // Ensure the lock target exists
26
+ if (!fs.existsSync(CLAUDE_CONFIG_DIR)) {
27
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
28
+ }
29
+ // Create a marker file for the lock if it doesn't exist
30
+ const lockTarget = path.join(CLAUDE_CONFIG_DIR, ".playbook-lock-target");
31
+ if (!fs.existsSync(lockTarget)) {
32
+ fs.writeFileSync(lockTarget, "");
33
+ }
34
+ try {
35
+ const release = await lockfile.lock(lockTarget, LOCK_OPTIONS);
36
+ return release;
37
+ }
38
+ catch (err) {
39
+ if (err.code === "ELOCKED") {
40
+ throw new Error("Another sync operation is in progress. Please wait and try again.");
41
+ }
42
+ throw err;
43
+ }
44
+ }
45
+ // Token validation - supports both JWT and PAT
46
+ function isValidToken(token) {
47
+ // Check for PAT format: pat_XXXXXXXX_YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY (77 chars)
48
+ if (token.startsWith("pat_") && token.length === 77) {
49
+ // Validate format: pat_ + 8 hex chars + _ + 64 hex chars
50
+ const patRegex = /^pat_[a-f0-9]{8}_[a-f0-9]{64}$/i;
51
+ if (patRegex.test(token)) {
52
+ return { valid: true, type: "pat" };
53
+ }
54
+ }
55
+ // Check for JWT format
56
+ const parts = token.split(".");
57
+ if (parts.length === 3) {
58
+ try {
59
+ // Check header and payload are valid base64
60
+ for (let i = 0; i < 2; i++) {
61
+ const decoded = Buffer.from(parts[i], "base64url").toString();
62
+ JSON.parse(decoded);
63
+ }
64
+ return { valid: true, type: "jwt" };
65
+ }
66
+ catch {
67
+ // Fall through
68
+ }
69
+ }
70
+ return { valid: false, type: null };
71
+ }
72
+ // Decode JWT to check expiration
73
+ function decodeJWT(token) {
74
+ try {
75
+ const parts = token.split(".");
76
+ const payload = Buffer.from(parts[1], "base64url").toString();
77
+ return JSON.parse(payload);
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ // Check if Claude Code is running (platform-specific, using safe spawnSync)
84
+ function isClaudeCodeRunning() {
85
+ try {
86
+ const platform = os.platform();
87
+ if (platform === "darwin" || platform === "linux") {
88
+ // Use pgrep with fixed arguments (no shell injection possible)
89
+ const result = spawnSync("pgrep", ["-f", "claude"], {
90
+ encoding: "utf-8",
91
+ stdio: ["pipe", "pipe", "pipe"],
92
+ });
93
+ return result.status === 0 && result.stdout.trim().length > 0;
94
+ }
95
+ else if (platform === "win32") {
96
+ // Use tasklist with fixed arguments
97
+ const result = spawnSync("tasklist", ["/FI", "IMAGENAME eq claude*"], {
98
+ encoding: "utf-8",
99
+ stdio: ["pipe", "pipe", "pipe"],
100
+ });
101
+ return result.stdout?.toLowerCase().includes("claude") ?? false;
102
+ }
103
+ return false;
104
+ }
105
+ catch {
106
+ // If check fails, assume not running
107
+ return false;
108
+ }
109
+ }
110
+ // Create backup of settings file
111
+ function backupSettings() {
112
+ if (!fs.existsSync(SETTINGS_FILE)) {
113
+ return null;
114
+ }
115
+ try {
116
+ if (!fs.existsSync(BACKUP_DIR)) {
117
+ fs.mkdirSync(BACKUP_DIR, { recursive: true });
118
+ }
119
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
120
+ const backupFile = path.join(BACKUP_DIR, `settings.${timestamp}.json`);
121
+ fs.copyFileSync(SETTINGS_FILE, backupFile);
122
+ // Clean up old backups (keep last 5)
123
+ const backups = fs.readdirSync(BACKUP_DIR)
124
+ .filter((f) => f.startsWith("settings.") && f.endsWith(".json"))
125
+ .sort()
126
+ .reverse();
127
+ for (const old of backups.slice(5)) {
128
+ fs.unlinkSync(path.join(BACKUP_DIR, old));
129
+ }
130
+ return backupFile;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ // Read settings with error handling
137
+ function readSettings() {
138
+ if (!fs.existsSync(SETTINGS_FILE)) {
139
+ return {};
140
+ }
141
+ try {
142
+ const content = fs.readFileSync(SETTINGS_FILE, "utf-8");
143
+ return JSON.parse(content);
144
+ }
145
+ catch (error) {
146
+ throw new Error(`Failed to parse settings.json: ${error.message}`);
147
+ }
148
+ }
149
+ // Write settings atomically with safety checks
150
+ function writeSettings(settings) {
151
+ if (!fs.existsSync(CLAUDE_CONFIG_DIR)) {
152
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
153
+ }
154
+ // Security check: refuse to write if settings file is a symlink
155
+ if (fs.existsSync(SETTINGS_FILE)) {
156
+ const stats = fs.lstatSync(SETTINGS_FILE);
157
+ if (stats.isSymbolicLink()) {
158
+ throw new Error("Security: settings.json is a symlink, refusing to write");
159
+ }
160
+ }
161
+ // Write atomically via temp file
162
+ const tempFile = `${SETTINGS_FILE}.tmp`;
163
+ fs.writeFileSync(tempFile, JSON.stringify(settings, null, 2));
164
+ fs.renameSync(tempFile, SETTINGS_FILE);
165
+ }
166
+ // Validate item ID format to prevent path traversal
167
+ function isValidItemId(id) {
168
+ return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100;
169
+ }
170
+ // Sanitize string for safe storage
171
+ function sanitizeString(str, maxLength = 500) {
172
+ return str
173
+ .slice(0, maxLength)
174
+ .replace(/[\x00-\x1F\x7F]/g, "") // Remove control characters
175
+ .trim();
176
+ }
177
+ // Prompt for user confirmation
178
+ async function confirm(message) {
179
+ return new Promise((resolve) => {
180
+ const rl = readline.createInterface({
181
+ input: process.stdin,
182
+ output: process.stdout,
183
+ });
184
+ rl.question(`${message} (y/N) `, (answer) => {
185
+ rl.close();
186
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
187
+ });
188
+ });
189
+ }
190
+ // Prompt for token input with hidden characters
191
+ async function promptForToken() {
192
+ return new Promise((resolve) => {
193
+ const rl = readline.createInterface({
194
+ input: process.stdin,
195
+ output: process.stdout,
196
+ });
197
+ process.stdout.write(chalk.cyan("Paste your Playbook token: "));
198
+ let token = "";
199
+ process.stdin.setRawMode?.(true);
200
+ process.stdin.resume();
201
+ process.stdin.setEncoding("utf8");
202
+ const onData = (char) => {
203
+ if (char === "\n" || char === "\r") {
204
+ process.stdin.setRawMode?.(false);
205
+ process.stdin.removeListener("data", onData);
206
+ console.log(); // New line
207
+ rl.close();
208
+ resolve(token);
209
+ }
210
+ else if (char === "\x7F" || char === "\b") {
211
+ // Backspace
212
+ if (token.length > 0) {
213
+ token = token.slice(0, -1);
214
+ process.stdout.write("\b \b");
215
+ }
216
+ }
217
+ else if (char === "\x03") {
218
+ // Ctrl+C
219
+ process.exit(0);
220
+ }
221
+ else {
222
+ token += char;
223
+ process.stdout.write("*");
224
+ }
225
+ };
226
+ process.stdin.on("data", onData);
227
+ });
228
+ }
229
+ // Fetch playbook from cloud
230
+ async function fetchPlaybook(token, supabaseUrl) {
231
+ const functionsUrl = supabaseUrl.replace(".supabase.co", ".functions.supabase.co");
232
+ const response = await fetch(`${functionsUrl}/playbook`, {
233
+ method: "GET",
234
+ headers: {
235
+ Authorization: `Bearer ${token}`,
236
+ "Content-Type": "application/json",
237
+ },
238
+ });
239
+ if (!response.ok) {
240
+ if (response.status === 401) {
241
+ throw new Error("Token expired or invalid. Please generate a new token from the Playbook web app.");
242
+ }
243
+ const error = await response.json().catch(() => ({}));
244
+ throw new Error(error.error || `API error: ${response.statusText}`);
245
+ }
246
+ return response.json();
247
+ }
248
+ // Save sync metadata
249
+ function saveSyncMeta(userId, itemCount) {
250
+ const meta = {
251
+ lastSyncedAt: new Date().toISOString(),
252
+ userId,
253
+ itemCount,
254
+ };
255
+ fs.writeFileSync(SYNC_META_FILE, JSON.stringify(meta, null, 2));
256
+ }
257
+ // Push installation status to cloud
258
+ async function pushInstalledStatus(token, supabaseUrl, mcpServerIds) {
259
+ const items = getSafeInstalledItems(mcpServerIds);
260
+ if (items.length === 0) {
261
+ return { pushed: 0 };
262
+ }
263
+ const functionsUrl = supabaseUrl.replace(".supabase.co", ".functions.supabase.co");
264
+ try {
265
+ const response = await fetch(`${functionsUrl}/playbook/installed`, {
266
+ method: "POST",
267
+ headers: {
268
+ Authorization: `Bearer ${token}`,
269
+ "Content-Type": "application/json",
270
+ },
271
+ body: JSON.stringify({
272
+ items: items.map((item) => ({
273
+ type: item.type,
274
+ id: item.id,
275
+ name: item.name,
276
+ })),
277
+ syncedAt: new Date().toISOString(),
278
+ }),
279
+ });
280
+ if (!response.ok) {
281
+ const errorData = await response.json().catch(() => ({}));
282
+ return {
283
+ pushed: 0,
284
+ error: errorData.error || `HTTP ${response.status}`,
285
+ };
286
+ }
287
+ const data = await response.json();
288
+ return { pushed: data.synced || items.length };
289
+ }
290
+ catch (err) {
291
+ return {
292
+ pushed: 0,
293
+ error: err instanceof Error ? err.message : "Unknown error",
294
+ };
295
+ }
296
+ }
297
+ // Main sync logic
298
+ async function syncPlaybook(token, supabaseUrl, options) {
299
+ const spinner = ora("Fetching your Playbook...").start();
300
+ try {
301
+ // Fetch playbook from cloud
302
+ const playbook = await fetchPlaybook(token, supabaseUrl);
303
+ spinner.text = `Found ${playbook.items.length} items for ${playbook.user.email}`;
304
+ if (playbook.items.length === 0) {
305
+ spinner.info("Your Playbook is empty. Add items on the web app first.");
306
+ return;
307
+ }
308
+ // Group items by type
309
+ const mcps = playbook.items.filter((i) => i.item_type === "mcp");
310
+ const skills = playbook.items.filter((i) => i.item_type === "skill");
311
+ const others = playbook.items.filter((i) => !["mcp", "skill"].includes(i.item_type));
312
+ spinner.succeed(`Fetched ${playbook.items.length} items`);
313
+ console.log(chalk.gray(`\n MCPs: ${mcps.length}`));
314
+ console.log(chalk.gray(` Skills: ${skills.length}`));
315
+ console.log(chalk.gray(` Other: ${others.length}\n`));
316
+ if (options.dryRun) {
317
+ console.log(chalk.yellow("Dry run mode - no changes will be made\n"));
318
+ }
319
+ // Safety check: warn if Claude Code is running
320
+ if (!options.dryRun && isClaudeCodeRunning()) {
321
+ console.log(chalk.yellow("\nWarning: Claude Code appears to be running."));
322
+ console.log(chalk.gray(" Modifying settings while Claude Code is running may require a restart."));
323
+ if (!options.force) {
324
+ const proceed = await confirm(chalk.yellow(" Continue anyway?"));
325
+ if (!proceed) {
326
+ console.log(chalk.gray("\nSync cancelled. Close Claude Code and try again."));
327
+ return;
328
+ }
329
+ }
330
+ console.log();
331
+ }
332
+ // Process MCPs
333
+ if (mcps.length > 0) {
334
+ const mcpSpinner = ora("Installing MCPs to settings.json...").start();
335
+ // Read current settings
336
+ let settings;
337
+ try {
338
+ settings = readSettings();
339
+ }
340
+ catch (error) {
341
+ mcpSpinner.fail("Failed to read settings");
342
+ throw error;
343
+ }
344
+ settings.mcpServers = settings.mcpServers || {};
345
+ // Count new MCPs
346
+ let toInstall = 0;
347
+ let skipped = 0;
348
+ for (const mcp of mcps) {
349
+ if (!isValidItemId(mcp.item_id)) {
350
+ skipped++;
351
+ continue;
352
+ }
353
+ if (settings.mcpServers[mcp.item_id]) {
354
+ skipped++;
355
+ continue;
356
+ }
357
+ toInstall++;
358
+ }
359
+ if (toInstall === 0) {
360
+ mcpSpinner.info(`All ${mcps.length} MCPs already installed`);
361
+ }
362
+ else {
363
+ mcpSpinner.stop();
364
+ // Show what will be installed
365
+ console.log(chalk.cyan(`\n MCPs to install (${toInstall}):`));
366
+ for (const mcp of mcps) {
367
+ if (!isValidItemId(mcp.item_id)) {
368
+ console.log(chalk.yellow(` ! ${mcp.item_id} (invalid ID, skipping)`));
369
+ continue;
370
+ }
371
+ if (settings.mcpServers[mcp.item_id]) {
372
+ continue; // Already installed
373
+ }
374
+ console.log(chalk.gray(` + ${sanitizeString(mcp.item_name, 50)}`));
375
+ }
376
+ console.log();
377
+ // Confirm installation
378
+ if (!options.dryRun && !options.force) {
379
+ const proceed = await confirm(chalk.cyan(` Install ${toInstall} MCP(s) to settings.json?`));
380
+ if (!proceed) {
381
+ console.log(chalk.gray("\nInstallation cancelled."));
382
+ return;
383
+ }
384
+ }
385
+ if (!options.dryRun) {
386
+ // Create backup before modifying
387
+ if (!options.noBackup) {
388
+ const backupPath = backupSettings();
389
+ if (backupPath) {
390
+ console.log(chalk.gray(`\n Backup created: ${backupPath}`));
391
+ }
392
+ }
393
+ // Install MCPs
394
+ let installed = 0;
395
+ for (const mcp of mcps) {
396
+ if (!isValidItemId(mcp.item_id))
397
+ continue;
398
+ if (settings.mcpServers[mcp.item_id])
399
+ continue;
400
+ settings.mcpServers[mcp.item_id] = {
401
+ // Note: Command and args need to be configured by user
402
+ // We create a placeholder entry
403
+ };
404
+ installed++;
405
+ }
406
+ writeSettings(settings);
407
+ console.log(chalk.green(`\n Installed ${installed} MCP(s)`));
408
+ }
409
+ else {
410
+ console.log(chalk.yellow(` Would install ${toInstall} MCP(s) (dry run)`));
411
+ }
412
+ }
413
+ }
414
+ // Process Skills
415
+ if (skills.length > 0) {
416
+ console.log(chalk.cyan(`\n Skills in your Playbook (${skills.length}):`));
417
+ console.log(chalk.gray(" (Skills are built-in and don't need installation)"));
418
+ for (const skill of skills) {
419
+ console.log(chalk.gray(` - ${sanitizeString(skill.item_name, 50)}`));
420
+ }
421
+ }
422
+ // Process other items
423
+ if (others.length > 0) {
424
+ console.log(chalk.cyan(`\n Other items (${others.length}):`));
425
+ console.log(chalk.gray(" (Reference items - no local installation needed)"));
426
+ for (const item of others) {
427
+ console.log(chalk.gray(` - [${item.item_type}] ${sanitizeString(item.item_name, 50)}`));
428
+ }
429
+ }
430
+ // Save sync metadata
431
+ if (!options.dryRun) {
432
+ saveSyncMeta(playbook.user.id, playbook.items.length);
433
+ }
434
+ console.log(chalk.green("\nSync complete!"));
435
+ if (mcps.length > 0) {
436
+ console.log(chalk.yellow("\nNote: Newly added MCPs may need configuration."));
437
+ console.log(chalk.gray(` Edit ${SETTINGS_FILE} to add command and args.`));
438
+ console.log(chalk.gray(" Documentation: https://claude.ai/claude-code/mcps"));
439
+ }
440
+ }
441
+ catch (error) {
442
+ spinner.fail("Sync failed");
443
+ throw error;
444
+ }
445
+ }
446
+ export const syncCommand = new Command("sync")
447
+ .description("Sync your Playbook bidirectionally: pull items from web, push installation status back")
448
+ .option("--token <token>", "Playbook auth token (or paste when prompted)")
449
+ .option("--supabase-url <url>", "Supabase project URL", process.env.PLAYBOOK_SUPABASE_URL)
450
+ .option("--dry-run", "Show what would be synced without making changes")
451
+ .option("-f, --force", "Skip confirmation prompts")
452
+ .option("--no-backup", "Skip creating backup of settings.json")
453
+ .option("--no-push", "Skip pushing installation status to cloud")
454
+ .addHelpText("after", `
455
+ Examples:
456
+ $ npx claude-playbook sync # Full bidirectional sync
457
+ $ npx claude-playbook sync --no-push # Pull only (don't update web badges)
458
+ $ npx claude-playbook sync --dry-run # Preview what would happen
459
+ $ npx claude-playbook sync --force # Skip all confirmations
460
+
461
+ Authentication:
462
+ Supports two token types:
463
+ - JWT: Short-lived session token from /auth/cli (expires in ~1 hour)
464
+ - PAT: Personal Access Token from Settings > Access Tokens (long-lived)
465
+
466
+ Environment:
467
+ PLAYBOOK_SUPABASE_URL Supabase project URL (avoids prompting)
468
+ `)
469
+ .action(async (options) => {
470
+ // Check for Supabase URL
471
+ const supabaseUrl = options.supabaseUrl || process.env.PLAYBOOK_SUPABASE_URL;
472
+ if (!supabaseUrl) {
473
+ console.error(chalk.red("Error: Supabase URL not configured"));
474
+ console.log(chalk.gray("\nSet PLAYBOOK_SUPABASE_URL environment variable or use --supabase-url"));
475
+ console.log(chalk.gray("Example: export PLAYBOOK_SUPABASE_URL=https://your-project.supabase.co"));
476
+ process.exit(1);
477
+ }
478
+ console.log(chalk.bold("\nPlaybook Sync"));
479
+ console.log(chalk.gray("Syncing your Playbook items to local Claude Code config\n"));
480
+ // Get token
481
+ let token = options.token;
482
+ if (!token) {
483
+ console.log(chalk.gray("Authentication options:"));
484
+ console.log(chalk.gray(" 1. Session token: https://claude-code-playbook.vercel.app/auth/cli"));
485
+ console.log(chalk.gray(" 2. Personal Access Token: Settings > Access Tokens (recommended)\n"));
486
+ token = await promptForToken();
487
+ }
488
+ // Validate token format (JWT or PAT)
489
+ const tokenValidation = isValidToken(token);
490
+ if (!tokenValidation.valid) {
491
+ console.error(chalk.red("\nError: Invalid token format"));
492
+ console.log(chalk.gray("Supported formats:"));
493
+ console.log(chalk.gray(" - JWT: Session token from web app (short-lived)"));
494
+ console.log(chalk.gray(" - PAT: Personal Access Token from Settings > Access Tokens (long-lived)"));
495
+ console.log(chalk.gray("\nMake sure you copied the complete token."));
496
+ process.exit(1);
497
+ }
498
+ // Token type-specific handling
499
+ if (tokenValidation.type === "pat") {
500
+ console.log(chalk.gray("Using Personal Access Token"));
501
+ // PATs don't have client-side expiration check - server validates
502
+ }
503
+ else {
504
+ // JWT: Check token expiration
505
+ const decoded = decodeJWT(token);
506
+ if (decoded?.exp) {
507
+ const expiresAt = new Date(decoded.exp * 1000);
508
+ const now = new Date();
509
+ if (expiresAt < now) {
510
+ console.error(chalk.red("\nError: Token has expired"));
511
+ console.log(chalk.gray(`Expired at: ${expiresAt.toLocaleString()}`));
512
+ console.log(chalk.gray("Please generate a new token from the web app."));
513
+ console.log(chalk.gray("\nTip: Create a Personal Access Token for long-lived authentication:"));
514
+ console.log(chalk.gray(" Settings > Access Tokens > New Token"));
515
+ process.exit(1);
516
+ }
517
+ const minutesRemaining = Math.round((expiresAt.getTime() - now.getTime()) / 60000);
518
+ if (minutesRemaining < 5) {
519
+ console.log(chalk.yellow(`\nWarning: Token expires in ${minutesRemaining} minutes`));
520
+ console.log(chalk.gray("Tip: Use a Personal Access Token for long-lived authentication"));
521
+ }
522
+ }
523
+ }
524
+ let releaseLock = null;
525
+ try {
526
+ // Acquire exclusive lock
527
+ const lockSpinner = ora("Acquiring sync lock...").start();
528
+ try {
529
+ releaseLock = await acquireLock();
530
+ lockSpinner.succeed("Lock acquired");
531
+ }
532
+ catch (lockErr) {
533
+ lockSpinner.fail("Could not acquire lock");
534
+ throw lockErr;
535
+ }
536
+ await syncPlaybook(token, supabaseUrl, {
537
+ dryRun: options.dryRun || false,
538
+ force: options.force || false,
539
+ noBackup: options.backup === false,
540
+ });
541
+ // Push installation status to cloud (unless --no-push)
542
+ if (!options.dryRun && options.push !== false) {
543
+ const pushSpinner = ora("Syncing installation status to cloud...").start();
544
+ const settings = readSettings();
545
+ const mcpIds = Object.keys(settings.mcpServers || {});
546
+ const pushResult = await pushInstalledStatus(token, supabaseUrl, mcpIds);
547
+ if (pushResult.error) {
548
+ pushSpinner.warn(`Could not sync status to cloud: ${pushResult.error} (non-fatal)`);
549
+ }
550
+ else if (pushResult.pushed > 0) {
551
+ pushSpinner.succeed(`Synced ${pushResult.pushed} item(s) as "installed" on web`);
552
+ }
553
+ else {
554
+ pushSpinner.info("No items to sync to cloud");
555
+ }
556
+ }
557
+ }
558
+ catch (error) {
559
+ console.error(chalk.red(`\n${error.message}`));
560
+ process.exit(1);
561
+ }
562
+ finally {
563
+ // Always release lock
564
+ if (releaseLock) {
565
+ await releaseLock();
566
+ }
567
+ }
568
+ });
@@ -0,0 +1,14 @@
1
+ export interface SafeInstalledItem {
2
+ type: "mcp" | "skill";
3
+ id: string;
4
+ name: string;
5
+ installed: true;
6
+ }
7
+ /**
8
+ * Get installed items in a format safe for web sync.
9
+ * IMPORTANT: This strips all sensitive data:
10
+ * - No env variables (API keys, tokens)
11
+ * - No command paths (local system info)
12
+ * - No args (may contain tokens)
13
+ */
14
+ export declare function getSafeInstalledItems(mcpServerIds: string[]): SafeInstalledItem[];
@@ -0,0 +1,55 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ const CLAUDE_DIR = path.join(os.homedir(), ".claude");
5
+ /**
6
+ * Get installed items in a format safe for web sync.
7
+ * IMPORTANT: This strips all sensitive data:
8
+ * - No env variables (API keys, tokens)
9
+ * - No command paths (local system info)
10
+ * - No args (may contain tokens)
11
+ */
12
+ export function getSafeInstalledItems(mcpServerIds) {
13
+ const items = [];
14
+ // MCPs - only use IDs passed in (already validated)
15
+ for (const id of mcpServerIds) {
16
+ // Validate ID format for safety
17
+ if (/^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100) {
18
+ items.push({
19
+ type: "mcp",
20
+ id,
21
+ name: id,
22
+ installed: true,
23
+ });
24
+ }
25
+ }
26
+ // Skills - read from ~/.claude/skills/
27
+ const skillsDir = path.join(CLAUDE_DIR, "skills");
28
+ if (fs.existsSync(skillsDir)) {
29
+ try {
30
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
31
+ for (const entry of entries) {
32
+ if (entry.isDirectory()) {
33
+ const skillFile = path.join(skillsDir, entry.name, "skill.md");
34
+ const indexFile = path.join(skillsDir, entry.name, "index.md");
35
+ if (fs.existsSync(skillFile) || fs.existsSync(indexFile)) {
36
+ // Validate name format
37
+ if (/^[a-zA-Z0-9_-]+$/.test(entry.name) &&
38
+ entry.name.length <= 100) {
39
+ items.push({
40
+ type: "skill",
41
+ id: entry.name,
42
+ name: entry.name,
43
+ installed: true,
44
+ });
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ catch {
51
+ // Ignore read errors
52
+ }
53
+ }
54
+ return items;
55
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { searchCommand } from "./commands/search.js";
4
+ import { installCommand } from "./commands/install.js";
5
+ import { configCommand } from "./commands/config.js";
6
+ import { listCommand } from "./commands/list.js";
7
+ import { syncCommand } from "./commands/sync.js";
8
+ const program = new Command();
9
+ program
10
+ .name("claude-playbook")
11
+ .description("Discover and install Claude Code skills, agents, and MCPs")
12
+ .version("0.1.0");
13
+ program.addCommand(searchCommand);
14
+ program.addCommand(installCommand);
15
+ program.addCommand(configCommand);
16
+ program.addCommand(listCommand);
17
+ program.addCommand(syncCommand);
18
+ program.parse();
@@ -0,0 +1,29 @@
1
+ export interface RegistryItem {
2
+ id: string;
3
+ name: string;
4
+ description: string;
5
+ category?: string;
6
+ }
7
+ export interface Skill extends RegistryItem {
8
+ invocation: string;
9
+ source: string;
10
+ }
11
+ export interface Agent extends RegistryItem {
12
+ invocation: string;
13
+ capabilities?: string[];
14
+ }
15
+ export interface MCP extends RegistryItem {
16
+ provider?: string;
17
+ toolCount: number;
18
+ tools: string[];
19
+ installUrl?: string;
20
+ documentationUrl?: string;
21
+ }
22
+ export type ItemType = "skill" | "agent" | "mcp";
23
+ export declare function fetchSkills(): Promise<Skill[]>;
24
+ export declare function fetchAgents(): Promise<Agent[]>;
25
+ export declare function fetchMCPs(): Promise<MCP[]>;
26
+ export declare function searchRegistry(query: string, type?: ItemType): Promise<{
27
+ type: ItemType;
28
+ item: RegistryItem;
29
+ }[]>;
@@ -0,0 +1,68 @@
1
+ // Registry fetching utilities
2
+ const REGISTRY_BASE = "https://raw.githubusercontent.com/AnobleSCM/claude-code-registry/main";
3
+ async function fetchJson(url) {
4
+ const response = await fetch(url);
5
+ if (!response.ok) {
6
+ throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
7
+ }
8
+ return response.json();
9
+ }
10
+ export async function fetchSkills() {
11
+ return fetchJson(`${REGISTRY_BASE}/skills/index.json`);
12
+ }
13
+ export async function fetchAgents() {
14
+ return fetchJson(`${REGISTRY_BASE}/agents/index.json`);
15
+ }
16
+ export async function fetchMCPs() {
17
+ return fetchJson(`${REGISTRY_BASE}/mcps/index.json`);
18
+ }
19
+ export async function searchRegistry(query, type) {
20
+ const results = [];
21
+ const queryLower = query.toLowerCase();
22
+ const matchesQuery = (item) => {
23
+ return (item.name.toLowerCase().includes(queryLower) ||
24
+ item.description.toLowerCase().includes(queryLower) ||
25
+ item.category?.toLowerCase().includes(queryLower) ||
26
+ false);
27
+ };
28
+ if (!type || type === "skill") {
29
+ try {
30
+ const skills = await fetchSkills();
31
+ for (const skill of skills) {
32
+ if (matchesQuery(skill)) {
33
+ results.push({ type: "skill", item: skill });
34
+ }
35
+ }
36
+ }
37
+ catch {
38
+ // Registry might not exist yet
39
+ }
40
+ }
41
+ if (!type || type === "agent") {
42
+ try {
43
+ const agents = await fetchAgents();
44
+ for (const agent of agents) {
45
+ if (matchesQuery(agent)) {
46
+ results.push({ type: "agent", item: agent });
47
+ }
48
+ }
49
+ }
50
+ catch {
51
+ // Registry might not exist yet
52
+ }
53
+ }
54
+ if (!type || type === "mcp") {
55
+ try {
56
+ const mcps = await fetchMCPs();
57
+ for (const mcp of mcps) {
58
+ if (matchesQuery(mcp)) {
59
+ results.push({ type: "mcp", item: mcp });
60
+ }
61
+ }
62
+ }
63
+ catch {
64
+ // Registry might not exist yet
65
+ }
66
+ }
67
+ return results;
68
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "claude-playbook",
3
+ "version": "0.1.0",
4
+ "description": "CLI for discovering and installing Claude Code skills, agents, and MCPs",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "claude-playbook": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/AnobleSCM/claude-code-playbook.git",
17
+ "directory": "packages/cli"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/AnobleSCM/claude-code-playbook/issues"
21
+ },
22
+ "homepage": "https://github.com/AnobleSCM/claude-code-playbook/tree/main/packages/cli#readme",
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "dev": "tsc -w",
26
+ "start": "node dist/index.js",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "keywords": [
30
+ "claude",
31
+ "claude-code",
32
+ "ai",
33
+ "cli",
34
+ "mcp",
35
+ "skills",
36
+ "anthropic"
37
+ ],
38
+ "author": "AnobleSCM",
39
+ "license": "MIT",
40
+ "dependencies": {
41
+ "chalk": "^5.3.0",
42
+ "commander": "^12.0.0",
43
+ "ora": "^8.0.1",
44
+ "proper-lockfile": "^4.1.2"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^20.11.0",
48
+ "@types/proper-lockfile": "^4.1.4",
49
+ "typescript": "^5.3.0"
50
+ },
51
+ "engines": {
52
+ "node": ">=18"
53
+ }
54
+ }