claude-cwc 0.2.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,53 @@
1
+ import { Router as createRouter } from 'express';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import matter from 'gray-matter';
5
+ const WORKFLOW_ID_REGEX = /<!-- cwc:workflow:([^:\s>]+) -->/;
6
+ export function exportedWorkflowsRouter(homeDir) {
7
+ const router = createRouter();
8
+ const skillsDir = path.join(homeDir, '.claude', 'skills');
9
+ router.get('/', async (_req, res) => {
10
+ const results = [];
11
+ try {
12
+ const dirs = await fs.readdir(skillsDir);
13
+ for (const slug of dirs) {
14
+ const skillFile = path.join(skillsDir, slug, 'SKILL.md');
15
+ try {
16
+ const raw = await fs.readFile(skillFile, 'utf-8');
17
+ if (!WORKFLOW_ID_REGEX.test(raw))
18
+ continue;
19
+ const { data } = matter(raw);
20
+ results.push({
21
+ slug,
22
+ name: String(data['name'] ?? slug),
23
+ description: String(data['description'] ?? ''),
24
+ skillDir: path.join(skillsDir, slug),
25
+ });
26
+ }
27
+ catch { /* skip */ }
28
+ }
29
+ }
30
+ catch { /* skills dir missing */ }
31
+ res.json(results);
32
+ });
33
+ router.delete('/', async (req, res) => {
34
+ const slug = req.query['slug'];
35
+ if (!slug)
36
+ return void res.status(400).json({ error: 'slug required' });
37
+ const skillDir = path.join(skillsDir, slug);
38
+ try {
39
+ await fs.access(skillDir);
40
+ }
41
+ catch {
42
+ return void res.status(404).json({ error: 'not found' });
43
+ }
44
+ try {
45
+ await fs.rm(skillDir, { recursive: true, force: true });
46
+ res.json({ deleted: skillDir });
47
+ }
48
+ catch (err) {
49
+ res.status(500).json({ error: String(err) });
50
+ }
51
+ });
52
+ return router;
53
+ }
@@ -0,0 +1,31 @@
1
+ import { Router as createRouter } from 'express';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ export function fileContentRouter() {
6
+ const router = createRouter();
7
+ router.get('/', async (req, res) => {
8
+ const filePath = req.query['path'];
9
+ if (!filePath) {
10
+ res.status(400).json({ error: 'path query parameter required' });
11
+ return;
12
+ }
13
+ // Restrict to .claude directory to prevent arbitrary file reads.
14
+ // Use claudeDir + path.sep to avoid matching ~/.claudeevil/ etc.
15
+ const homeDir = os.homedir();
16
+ const claudeDir = path.join(homeDir, '.claude');
17
+ const resolved = path.resolve(filePath);
18
+ if (!resolved.startsWith(claudeDir + path.sep)) {
19
+ res.status(403).json({ error: 'Access restricted to .claude directory' });
20
+ return;
21
+ }
22
+ try {
23
+ const content = await fs.readFile(resolved, 'utf-8');
24
+ res.json({ content });
25
+ }
26
+ catch {
27
+ res.status(404).json({ error: 'File not found' });
28
+ }
29
+ });
30
+ return router;
31
+ }
@@ -0,0 +1,8 @@
1
+ import { Router as createRouter } from 'express';
2
+ export function healthRouter() {
3
+ const router = createRouter();
4
+ router.get('/', (_req, res) => {
5
+ res.json({ status: 'ok', version: '0.1.0' });
6
+ });
7
+ return router;
8
+ }
@@ -0,0 +1,32 @@
1
+ import { Router as createRouter } from 'express';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { exec } from 'node:child_process';
5
+ export function openFileRouter() {
6
+ const router = createRouter();
7
+ router.post('/', (req, res) => {
8
+ const { path: filePath } = req.body;
9
+ if (!filePath) {
10
+ res.status(400).json({ error: 'path body field required' });
11
+ return;
12
+ }
13
+ // Restrict to .claude directory.
14
+ // Use claudeDir + path.sep to avoid matching ~/.claudeevil/ etc.
15
+ const claudeDir = path.join(os.homedir(), '.claude');
16
+ const resolved = path.resolve(filePath);
17
+ if (!resolved.startsWith(claudeDir + path.sep)) {
18
+ res.status(403).json({ error: 'Access restricted to .claude directory' });
19
+ return;
20
+ }
21
+ // Use platform-appropriate open command
22
+ const cmd = process.platform === 'darwin' ? `open "${resolved}"` : `xdg-open "${resolved}"`;
23
+ exec(cmd, (err) => {
24
+ if (err) {
25
+ res.status(500).json({ error: 'Failed to open file' });
26
+ return;
27
+ }
28
+ res.json({ opened: true });
29
+ });
30
+ });
31
+ return router;
32
+ }
@@ -0,0 +1,40 @@
1
+ import { Router as createRouter } from 'express';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ const MAX_RECENTS = 10;
5
+ export function recentsRouter(recentsPath) {
6
+ const router = createRouter();
7
+ async function readRecents() {
8
+ try {
9
+ const raw = await fs.readFile(recentsPath, 'utf-8');
10
+ return JSON.parse(raw);
11
+ }
12
+ catch {
13
+ return [];
14
+ }
15
+ }
16
+ router.get('/', async (_req, res) => {
17
+ res.json(await readRecents());
18
+ });
19
+ router.delete('/', async (req, res) => {
20
+ const filePath = req.query['path'];
21
+ if (!filePath)
22
+ return void res.status(400).json({ error: 'path required' });
23
+ const existing = await readRecents();
24
+ const updated = existing.filter((p) => p !== filePath);
25
+ await fs.mkdir(path.dirname(recentsPath), { recursive: true });
26
+ await fs.writeFile(recentsPath, JSON.stringify(updated, null, 2), 'utf-8');
27
+ res.json(updated);
28
+ });
29
+ router.post('/', async (req, res) => {
30
+ const { path: filePath } = req.body;
31
+ if (!filePath)
32
+ return void res.status(400).json({ error: 'path required' });
33
+ const existing = await readRecents();
34
+ const updated = [filePath, ...existing.filter((p) => p !== filePath)].slice(0, MAX_RECENTS);
35
+ await fs.mkdir(path.dirname(recentsPath), { recursive: true });
36
+ await fs.writeFile(recentsPath, JSON.stringify(updated, null, 2), 'utf-8');
37
+ res.json(updated);
38
+ });
39
+ return router;
40
+ }
@@ -0,0 +1,55 @@
1
+ import { Router as createRouter } from 'express';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import matter from 'gray-matter';
5
+ export function skillsRouter(userHomeDir) {
6
+ const router = createRouter();
7
+ router.get('/', async (_req, res) => {
8
+ const userSkillsDir = path.join(userHomeDir, '.claude', 'skills');
9
+ const pluginCacheDir = path.join(userHomeDir, '.claude', 'plugins', 'cache');
10
+ const skills = [];
11
+ const CWC_WORKFLOW_MARKER = /<!-- cwc:workflow:[^:\s>]+ -->/;
12
+ // User skills — each subdir of userSkillsDir is a skill slug
13
+ try {
14
+ const dirs = await fs.readdir(userSkillsDir);
15
+ for (const slug of dirs) {
16
+ const skillFile = path.join(userSkillsDir, slug, 'SKILL.md');
17
+ try {
18
+ const raw = await fs.readFile(skillFile, 'utf-8');
19
+ if (CWC_WORKFLOW_MARKER.test(raw))
20
+ continue; // skip workflow-exported skills
21
+ const { data } = matter(raw);
22
+ skills.push({ slug, name: String(data['name'] ?? slug), description: String(data['description'] ?? ''), source: 'user', namespacedSlug: slug, filePath: skillFile });
23
+ }
24
+ catch { /* skip */ }
25
+ }
26
+ }
27
+ catch { /* dir missing */ }
28
+ // Plugin skills — walk cache/<publisher>/<plugin>/<version>/skills/
29
+ try {
30
+ const publishers = await fs.readdir(pluginCacheDir);
31
+ for (const publisher of publishers) {
32
+ const pluginNames = await fs.readdir(path.join(pluginCacheDir, publisher)).catch(() => []);
33
+ for (const pluginName of pluginNames) {
34
+ const versions = await fs.readdir(path.join(pluginCacheDir, publisher, pluginName)).catch(() => []);
35
+ const latestVersion = versions.sort().at(-1);
36
+ if (!latestVersion)
37
+ continue;
38
+ const skillsDir = path.join(pluginCacheDir, publisher, pluginName, latestVersion, 'skills');
39
+ const slugs = await fs.readdir(skillsDir).catch(() => []);
40
+ for (const slug of slugs) {
41
+ const skillFile = path.join(skillsDir, slug, 'SKILL.md');
42
+ try {
43
+ const { data } = matter(await fs.readFile(skillFile, 'utf-8'));
44
+ skills.push({ slug, name: String(data['name'] ?? slug), description: String(data['description'] ?? ''), source: 'plugin', namespacedSlug: `${pluginName}:${slug}`, filePath: skillFile });
45
+ }
46
+ catch { /* skip */ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ catch { /* no plugins */ }
52
+ res.json(skills);
53
+ });
54
+ return router;
55
+ }
@@ -0,0 +1,107 @@
1
+ import { Router as createRouter } from 'express';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { slugify } from '../../slugify.js';
6
+ export function workflowsRouter(workflowsDir, recentsPath) {
7
+ const router = createRouter();
8
+ router.get('/default-path', (req, res) => {
9
+ const name = req.query['name'] || 'untitled';
10
+ const slug = slugify(name) || 'untitled';
11
+ res.json({ path: path.join(os.homedir(), '.cwc', 'workflows', `${slug}.cwc`) });
12
+ });
13
+ router.get('/list', async (_req, res) => {
14
+ try {
15
+ await fs.mkdir(workflowsDir, { recursive: true });
16
+ const entries = await fs.readdir(workflowsDir);
17
+ const items = await Promise.all(entries
18
+ .filter((f) => f.endsWith('.cwc'))
19
+ .map(async (f) => {
20
+ const fullPath = path.join(workflowsDir, f);
21
+ try {
22
+ const raw = await fs.readFile(fullPath, 'utf-8');
23
+ const cwc = JSON.parse(raw);
24
+ return { path: fullPath, name: cwc.meta.name, updated: cwc.meta.updated, nodeCount: cwc.nodes.length };
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }));
30
+ res.json(items.filter(Boolean));
31
+ }
32
+ catch (err) {
33
+ res.status(500).json({ error: String(err) });
34
+ }
35
+ });
36
+ router.get('/', async (req, res) => {
37
+ const filePath = req.query['path'];
38
+ if (!filePath)
39
+ return void res.status(400).json({ error: 'path required' });
40
+ try {
41
+ const raw = await fs.readFile(filePath, 'utf-8');
42
+ res.json(JSON.parse(raw));
43
+ }
44
+ catch {
45
+ res.status(404).json({ error: 'not found' });
46
+ }
47
+ });
48
+ router.post('/', async (req, res) => {
49
+ const { path: filePath, content } = req.body;
50
+ if (!filePath || !content)
51
+ return void res.status(400).json({ error: 'path and content required' });
52
+ try {
53
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
54
+ await fs.writeFile(filePath, JSON.stringify(content, null, 2), 'utf-8');
55
+ res.json({ saved: true });
56
+ }
57
+ catch (err) {
58
+ res.status(500).json({ error: String(err) });
59
+ }
60
+ });
61
+ router.delete('/', async (req, res) => {
62
+ const filePath = req.query['path'];
63
+ if (!filePath)
64
+ return void res.status(400).json({ error: 'path required' });
65
+ try {
66
+ await fs.unlink(filePath);
67
+ res.json({ deleted: true });
68
+ }
69
+ catch {
70
+ res.status(404).json({ error: 'not found' });
71
+ }
72
+ });
73
+ router.post('/rename', async (req, res) => {
74
+ const { oldPath, newName } = req.body;
75
+ if (!oldPath || !newName)
76
+ return void res.status(400).json({ error: 'oldPath and newName required' });
77
+ const newSlug = slugify(newName) || 'untitled';
78
+ const dir = path.dirname(oldPath);
79
+ const newPath = path.join(dir, `${newSlug}.cwc`);
80
+ if (newPath === oldPath)
81
+ return void res.json({ path: oldPath, renamed: false });
82
+ if (await fs.access(newPath).then(() => true).catch(() => false)) {
83
+ return void res.status(400).json({ error: 'A workflow with that name already exists' });
84
+ }
85
+ let raw;
86
+ try {
87
+ raw = await fs.readFile(oldPath, 'utf-8');
88
+ }
89
+ catch {
90
+ return void res.status(404).json({ error: 'not found' });
91
+ }
92
+ const cwc = JSON.parse(raw);
93
+ cwc.meta.name = newName;
94
+ cwc.meta.updated = new Date().toISOString();
95
+ await fs.writeFile(newPath, JSON.stringify(cwc, null, 2), 'utf-8');
96
+ await fs.unlink(oldPath);
97
+ try {
98
+ const recentsRaw = await fs.readFile(recentsPath, 'utf-8');
99
+ const recents = JSON.parse(recentsRaw);
100
+ const updated = recents.map((p) => (p === oldPath ? newPath : p));
101
+ await fs.writeFile(recentsPath, JSON.stringify(updated, null, 2), 'utf-8');
102
+ }
103
+ catch { /* recents file missing or corrupt — skip */ }
104
+ res.json({ path: newPath, renamed: true });
105
+ });
106
+ return router;
107
+ }
@@ -0,0 +1,59 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import * as path from 'node:path';
4
+ import * as fs from 'node:fs';
5
+ import * as os from 'node:os';
6
+ import { healthRouter } from './api/health.js';
7
+ import { claudeCheckRouter } from './api/claude-check.js';
8
+ import { workflowsRouter } from './api/workflows.js';
9
+ import { agentsRouter } from './api/agents.js';
10
+ import { recentsRouter } from './api/recents.js';
11
+ import { exportRouter } from './api/export.js';
12
+ import { exportPreviewRouter } from './api/export-preview.js';
13
+ import { exportDeleteRouter } from './api/export-delete.js';
14
+ import { skillsRouter } from './api/skills.js';
15
+ import { fileContentRouter } from './api/file-content.js';
16
+ import { openFileRouter } from './api/open-file.js';
17
+ import { exportedWorkflowsRouter } from './api/exported-workflows.js';
18
+ export function createApp(opts) {
19
+ const app = express();
20
+ app.use(cors());
21
+ app.use(express.json({ limit: '10mb' }));
22
+ app.use('/api/health', healthRouter());
23
+ app.use('/api/claude-check', claudeCheckRouter());
24
+ const wfDir = opts.workflowsDir ?? path.join(os.homedir(), '.cwc', 'workflows');
25
+ const recPath = opts.recentsPath ?? path.join(os.homedir(), '.cwc', 'recents.json');
26
+ app.use('/api/workflows', workflowsRouter(wfDir, recPath));
27
+ const homeDir = opts.userHomeDir ?? os.homedir();
28
+ app.use('/api/agents', agentsRouter(homeDir));
29
+ app.use('/api/recents', recentsRouter(recPath));
30
+ app.use('/api/export/preview', exportPreviewRouter());
31
+ app.use('/api/export/delete', exportDeleteRouter());
32
+ app.use('/api/export', exportRouter());
33
+ app.use('/api/skills', skillsRouter(homeDir));
34
+ app.use('/api/file-content', fileContentRouter());
35
+ app.use('/api/open-file', openFileRouter());
36
+ app.use('/api/exported-workflows', exportedWorkflowsRouter(homeDir));
37
+ if (opts.staticDir && fs.existsSync(opts.staticDir)) {
38
+ app.use(express.static(opts.staticDir));
39
+ app.get('/{*path}', (_req, res) => {
40
+ res.sendFile(path.join(opts.staticDir, 'index.html'));
41
+ });
42
+ }
43
+ return app;
44
+ }
45
+ export function startServer(port, staticDir) {
46
+ const app = createApp({ staticDir });
47
+ return new Promise((resolve, reject) => {
48
+ const server = app.listen(port, () => {
49
+ console.log(`CWC server running on http://localhost:${port}`);
50
+ resolve();
51
+ });
52
+ server.on('error', (err) => {
53
+ if (err.code === 'EADDRINUSE') {
54
+ console.error(`Port ${port} is already in use. Run 'cwc stop' to kill the existing server.`);
55
+ }
56
+ reject(err);
57
+ });
58
+ });
59
+ }
@@ -0,0 +1,6 @@
1
+ import { startServer } from './index.js';
2
+ import * as path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const port = parseInt(process.argv[2] ?? '3579', 10);
5
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'client');
6
+ await startServer(port, staticDir);
@@ -0,0 +1,52 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import matter from 'gray-matter';
4
+ async function fileExists(p) {
5
+ try {
6
+ await fs.access(p);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ async function readSkillDescription(skillMdPath) {
14
+ try {
15
+ const content = await fs.readFile(skillMdPath, 'utf-8');
16
+ const { data } = matter(content);
17
+ return typeof data.description === 'string' ? data.description : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export async function resolveSkill(slug) {
24
+ const home = process.env.HOME ?? '';
25
+ if (slug.includes(':')) {
26
+ const [pluginKey, skillSlug] = slug.split(':');
27
+ // Look up installPath from installed_plugins.json
28
+ const pluginsJsonPath = path.join(home, '.claude', 'plugins', 'installed_plugins.json');
29
+ try {
30
+ const raw = await fs.readFile(pluginsJsonPath, 'utf-8');
31
+ const installed = JSON.parse(raw);
32
+ // Find plugin entry — key may be "pluginKey@publisher" or just "pluginKey"
33
+ const entry = Object.entries(installed).find(([k]) => k === pluginKey || k.startsWith(`${pluginKey}@`));
34
+ if (!entry)
35
+ return { slug, description: null, found: false };
36
+ const skillMdPath = path.join(entry[1].installPath, 'skills', skillSlug, 'SKILL.md');
37
+ if (!(await fileExists(skillMdPath)))
38
+ return { slug, description: null, found: false };
39
+ const description = await readSkillDescription(skillMdPath);
40
+ return { slug, description, found: true };
41
+ }
42
+ catch {
43
+ return { slug, description: null, found: false };
44
+ }
45
+ }
46
+ // Non-namespaced: ~/.claude/skills/<slug>/SKILL.md
47
+ const skillMdPath = path.join(home, '.claude', 'skills', slug, 'SKILL.md');
48
+ if (!(await fileExists(skillMdPath)))
49
+ return { slug, description: null, found: false };
50
+ const description = await readSkillDescription(skillMdPath);
51
+ return { slug, description, found: true };
52
+ }
@@ -0,0 +1,9 @@
1
+ export function slugify(name) {
2
+ return name
3
+ .toLowerCase()
4
+ .replace(/[\s_]+/g, '-')
5
+ .replace(/[^a-z0-9-]/g, '')
6
+ .replace(/-{2,}/g, '-')
7
+ .replace(/^-+|-+$/g, '')
8
+ .slice(0, 64);
9
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "claude-cwc",
3
+ "version": "0.2.0",
4
+ "description": "Visual composer for Claude Code multi-agent workflows",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "files": [
10
+ "dist/"
11
+ ],
12
+ "bin": {
13
+ "cwc": "dist/bin/cwc.js"
14
+ },
15
+ "scripts": {
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "build": "npm run build:server && npm run build:client",
19
+ "build:server": "tsc",
20
+ "build:client": "vite build --config client/vite.config.ts",
21
+ "dev:server": "tsc --watch",
22
+ "dev:client": "vite --config client/vite.config.ts",
23
+ "typecheck": "tsc --noEmit && tsc --noEmit -p client/tsconfig.json",
24
+ "start": "node dist/bin/cwc.js",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "devDependencies": {
28
+ "@types/cors": "^2.8.19",
29
+ "@types/express": "^5.0.6",
30
+ "@types/node": "^20.0.0",
31
+ "@types/open": "^6.1.0",
32
+ "@types/react": "^19.2.15",
33
+ "@types/react-dom": "^19.2.3",
34
+ "@types/uuid": "^10.0.0",
35
+ "@types/ws": "^8.18.1",
36
+ "@vitejs/plugin-react": "^4.7.0",
37
+ "typescript": "^5.4.0",
38
+ "vitest": "^1.6.0"
39
+ },
40
+ "dependencies": {
41
+ "@xyflow/react": "^12.10.2",
42
+ "cors": "^2.8.6",
43
+ "express": "^5.2.1",
44
+ "gray-matter": "^4.0.3",
45
+ "open": "^11.0.0",
46
+ "react": "^19.2.6",
47
+ "react-dom": "^19.2.6",
48
+ "uuid": "^9.0.0",
49
+ "ws": "^8.21.0"
50
+ }
51
+ }