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 +119 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +64 -0
- package/dist/commands/install.d.ts +2 -0
- package/dist/commands/install.js +82 -0
- package/dist/commands/list.d.ts +2 -0
- package/dist/commands/list.js +72 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +34 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +568 -0
- package/dist/config-reader.d.ts +14 -0
- package/dist/config-reader.js +55 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/registry.d.ts +29 -0
- package/dist/registry.js +68 -0
- package/package.json +54 -0
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,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,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,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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
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
|
+
}[]>;
|
package/dist/registry.js
ADDED
|
@@ -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
|
+
}
|