@guanmu/ccprofile 0.1.6 → 0.1.9

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.
Files changed (2) hide show
  1. package/dist/index.js +275 -80
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
+ import { createRequire } from "node:module";
3
4
  import path from "node:path";
4
- import { execSync } from "node:child_process";
5
+ import { execFileSync } from "node:child_process";
5
6
  import * as p from "@clack/prompts";
6
7
  const PROFILES_DIR = path.join(process.env.HOME, ".ccx", "profiles");
7
8
  const MARKETPLACES_DIR = path.join(process.env.HOME, ".claude", "plugins", "marketplaces");
@@ -11,24 +12,77 @@ function ensureProfilesDir() {
11
12
  function profilePath(name) {
12
13
  return path.join(PROFILES_DIR, `${name}.json`);
13
14
  }
15
+ function isValidProfileName(name) {
16
+ return /^[A-Za-z0-9._-]+$/.test(name) && name !== "." && name !== "..";
17
+ }
18
+ function normalizeProfileName(name) {
19
+ if (!name)
20
+ return undefined;
21
+ const normalized = name.trim();
22
+ if (!isValidProfileName(normalized)) {
23
+ console.error("Invalid profile name. Use only letters, numbers, dots, underscores, and hyphens.");
24
+ process.exitCode = 1;
25
+ return undefined;
26
+ }
27
+ return normalized;
28
+ }
29
+ function normalizePluginName(plugin) {
30
+ if (!plugin)
31
+ return undefined;
32
+ const normalized = plugin.trim();
33
+ if (!normalized) {
34
+ console.error("Plugin name is required.");
35
+ process.exitCode = 1;
36
+ return undefined;
37
+ }
38
+ return normalized;
39
+ }
14
40
  function readProfile(name) {
15
- const file = profilePath(name);
41
+ const normalized = normalizeProfileName(name);
42
+ if (!normalized)
43
+ process.exit(1);
44
+ const file = profilePath(normalized);
16
45
  if (!fs.existsSync(file)) {
17
- p.log.error(`Profile "${name}" not found.`);
46
+ p.log.error(`Profile "${normalized}" not found.`);
47
+ process.exit(1);
48
+ }
49
+ let data;
50
+ try {
51
+ data = JSON.parse(fs.readFileSync(file, "utf-8"));
52
+ }
53
+ catch {
54
+ p.log.error(`Invalid profile file "${normalized}". Expected valid JSON.`);
55
+ process.exit(1);
56
+ }
57
+ if (!data ||
58
+ typeof data !== "object" ||
59
+ !Array.isArray(data.plugins)) {
60
+ p.log.error(`Invalid profile file "${normalized}". Expected a plugins array.`);
18
61
  process.exit(1);
19
62
  }
20
- return JSON.parse(fs.readFileSync(file, "utf-8"));
63
+ const plugins = data.plugins.map((plugin) => typeof plugin === "string" ? plugin.trim() : undefined);
64
+ if (plugins.some((plugin) => !plugin)) {
65
+ p.log.error(`Invalid profile file "${normalized}". Plugin entries must be non-empty strings.`);
66
+ process.exit(1);
67
+ }
68
+ return {
69
+ name: normalized,
70
+ plugins: plugins,
71
+ };
21
72
  }
22
73
  function writeProfile(name, data) {
74
+ const normalized = normalizeProfileName(name);
75
+ if (!normalized)
76
+ return;
23
77
  ensureProfilesDir();
24
- fs.writeFileSync(profilePath(name), JSON.stringify(data, null, 2) + "\n");
78
+ fs.writeFileSync(profilePath(normalized), JSON.stringify({ ...data, name: normalized }, null, 2) + "\n");
25
79
  }
26
80
  function getProfileNames() {
27
81
  ensureProfilesDir();
28
82
  return fs
29
83
  .readdirSync(PROFILES_DIR)
30
84
  .filter((f) => f.endsWith(".json"))
31
- .map((f) => f.replace(".json", ""));
85
+ .map((f) => f.slice(0, -".json".length));
32
86
  }
33
87
  function getAllPlugins() {
34
88
  if (!fs.existsSync(MARKETPLACES_DIR))
@@ -39,27 +93,69 @@ function getAllPlugins() {
39
93
  const file = path.join(MARKETPLACES_DIR, dir, ".claude-plugin", "marketplace.json");
40
94
  if (!fs.existsSync(file))
41
95
  continue;
42
- const data = JSON.parse(fs.readFileSync(file, "utf-8"));
43
- for (const pl of data.plugins || []) {
96
+ let data;
97
+ try {
98
+ data = JSON.parse(fs.readFileSync(file, "utf-8"));
99
+ }
100
+ catch {
101
+ p.log.warn(`Skipping invalid marketplace "${dir}".`);
102
+ continue;
103
+ }
104
+ if (!data ||
105
+ typeof data !== "object" ||
106
+ !Array.isArray(data.plugins)) {
107
+ p.log.warn(`Skipping invalid marketplace "${dir}".`);
108
+ continue;
109
+ }
110
+ const marketplace = typeof data.name === "string"
111
+ ? (data.name || dir)
112
+ : dir;
113
+ for (const pl of data.plugins) {
114
+ if (!pl || typeof pl !== "object")
115
+ continue;
116
+ const name = pl.name;
117
+ if (typeof name !== "string" || !name.trim())
118
+ continue;
119
+ const description = pl.description;
120
+ const category = pl.category;
44
121
  plugins.push({
45
- name: pl.name,
46
- description: pl.description || "",
47
- category: pl.category,
48
- marketplace: data.name,
122
+ name: name.trim(),
123
+ description: typeof description === "string" ? description : "",
124
+ category: typeof category === "string" ? category : undefined,
125
+ marketplace,
49
126
  });
50
127
  }
51
128
  }
52
129
  return plugins;
53
130
  }
131
+ function canPrompt() {
132
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
133
+ }
134
+ function printNonInteractiveHelp() {
135
+ console.error("Interactive mode requires a TTY. Run `ccx --help` for command usage.");
136
+ printHelp();
137
+ }
138
+ function missingArg(message, usage) {
139
+ console.error(message);
140
+ console.error(`Usage: ${usage}`);
141
+ process.exitCode = 1;
142
+ }
54
143
  // ── Commands ──────────────────────────────────────────────
55
144
  async function addProfile(name) {
56
145
  if (!name) {
146
+ if (!canPrompt()) {
147
+ missingArg("Profile name is required.", "ccx create <name>");
148
+ return;
149
+ }
57
150
  name = (await p.text({
58
151
  message: "Profile name:",
59
152
  }));
60
153
  if (p.isCancel(name))
61
154
  return;
62
155
  }
156
+ name = normalizeProfileName(name);
157
+ if (!name)
158
+ return;
63
159
  const file = profilePath(name);
64
160
  if (fs.existsSync(file)) {
65
161
  p.log.error(`Profile "${name}" already exists.`);
@@ -69,12 +165,20 @@ async function addProfile(name) {
69
165
  p.log.success(`Created profile "${name}".`);
70
166
  }
71
167
  async function removeProfile(name) {
168
+ if (!name && !canPrompt()) {
169
+ missingArg("Profile name is required.", "ccx delete <name>");
170
+ return;
171
+ }
72
172
  const names = getProfileNames();
73
173
  if (names.length === 0) {
74
174
  p.log.warn("No profiles found.");
75
175
  return;
76
176
  }
77
177
  if (!name) {
178
+ if (!canPrompt()) {
179
+ missingArg("Profile name is required.", "ccx delete <name>");
180
+ return;
181
+ }
78
182
  name = (await p.select({
79
183
  message: "Select profile to remove:",
80
184
  options: names.map((n) => ({ value: n, label: n })),
@@ -82,6 +186,9 @@ async function removeProfile(name) {
82
186
  if (p.isCancel(name))
83
187
  return;
84
188
  }
189
+ name = normalizeProfileName(name);
190
+ if (!name)
191
+ return;
85
192
  const file = profilePath(name);
86
193
  if (!fs.existsSync(file)) {
87
194
  p.log.error(`Profile "${name}" not found.`);
@@ -91,22 +198,26 @@ async function removeProfile(name) {
91
198
  p.log.success(`Removed profile "${name}".`);
92
199
  }
93
200
  async function listProfiles() {
94
- ensureProfilesDir();
95
- const files = fs
96
- .readdirSync(PROFILES_DIR)
97
- .filter((f) => f.endsWith(".json"));
98
- if (files.length === 0) {
201
+ const names = getProfileNames();
202
+ if (names.length === 0) {
99
203
  p.log.warn("No profiles found.");
100
204
  return;
101
205
  }
102
- for (const f of files) {
103
- const data = JSON.parse(fs.readFileSync(path.join(PROFILES_DIR, f), "utf-8"));
206
+ for (const name of names) {
207
+ const data = readProfile(name);
104
208
  p.log.success(`${data.name} (${data.plugins.length} plugins)`);
105
209
  }
106
210
  }
107
211
  async function addPlugin(profileName, plugin) {
212
+ const normalizedProfileName = normalizeProfileName(profileName);
213
+ if (!normalizedProfileName)
214
+ return;
108
215
  const data = readProfile(profileName);
109
216
  if (!plugin) {
217
+ if (!canPrompt()) {
218
+ missingArg("Plugin name is required.", "ccx add <profile> <plugin>");
219
+ return;
220
+ }
110
221
  const allPlugins = getAllPlugins();
111
222
  const available = allPlugins.filter((pl) => !data.plugins.includes(pl.name));
112
223
  if (available.length === 0) {
@@ -137,21 +248,31 @@ async function addPlugin(profileName, plugin) {
137
248
  plugin = selected;
138
249
  }
139
250
  }
251
+ plugin = normalizePluginName(plugin);
252
+ if (!plugin)
253
+ return;
140
254
  if (data.plugins.includes(plugin)) {
141
255
  p.log.warn(`Plugin "${plugin}" already in profile.`);
142
256
  return;
143
257
  }
144
258
  data.plugins.push(plugin);
145
- writeProfile(profileName, data);
146
- p.log.success(`Added "${plugin}" to profile "${profileName}".`);
259
+ writeProfile(normalizedProfileName, data);
260
+ p.log.success(`Added "${plugin}" to profile "${normalizedProfileName}".`);
147
261
  }
148
262
  async function removePlugin(profileName, plugin) {
263
+ const normalizedProfileName = normalizeProfileName(profileName);
264
+ if (!normalizedProfileName)
265
+ return;
149
266
  const data = readProfile(profileName);
150
267
  if (data.plugins.length === 0) {
151
268
  p.log.warn("No plugins in this profile.");
152
269
  return;
153
270
  }
154
271
  if (!plugin) {
272
+ if (!canPrompt()) {
273
+ missingArg("Plugin name is required.", "ccx remove <profile> <plugin>");
274
+ return;
275
+ }
155
276
  plugin = (await p.select({
156
277
  message: `Remove plugin from "${profileName}":`,
157
278
  options: data.plugins.map((pl) => ({
@@ -162,19 +283,25 @@ async function removePlugin(profileName, plugin) {
162
283
  if (p.isCancel(plugin))
163
284
  return;
164
285
  }
286
+ plugin = normalizePluginName(plugin);
287
+ if (!plugin)
288
+ return;
165
289
  const idx = data.plugins.indexOf(plugin);
166
290
  if (idx === -1) {
167
291
  p.log.error(`Plugin "${plugin}" not found.`);
168
292
  return;
169
293
  }
170
294
  data.plugins.splice(idx, 1);
171
- writeProfile(profileName, data);
172
- p.log.success(`Removed "${plugin}" from profile "${profileName}".`);
295
+ writeProfile(normalizedProfileName, data);
296
+ p.log.success(`Removed "${plugin}" from profile "${normalizedProfileName}".`);
173
297
  }
174
298
  async function listPlugins(profileName) {
175
- const data = readProfile(profileName);
299
+ const normalizedProfileName = normalizeProfileName(profileName);
300
+ if (!normalizedProfileName)
301
+ return;
302
+ const data = readProfile(normalizedProfileName);
176
303
  if (data.plugins.length === 0) {
177
- p.log.warn(`No plugins in profile "${profileName}".`);
304
+ p.log.warn(`No plugins in profile "${normalizedProfileName}".`);
178
305
  return;
179
306
  }
180
307
  for (const pl of data.plugins) {
@@ -183,6 +310,10 @@ async function listPlugins(profileName) {
183
310
  }
184
311
  async function searchPlugins(keyword) {
185
312
  if (!keyword) {
313
+ if (!canPrompt()) {
314
+ missingArg("Search keyword is required.", "ccx search <keyword>");
315
+ return;
316
+ }
186
317
  keyword = (await p.text({
187
318
  message: "Search plugins:",
188
319
  }));
@@ -217,19 +348,22 @@ async function searchPlugins(keyword) {
217
348
  }
218
349
  }
219
350
  async function executeProfile(profileName) {
220
- const data = readProfile(profileName);
351
+ const normalizedProfileName = normalizeProfileName(profileName);
352
+ if (!normalizedProfileName)
353
+ return;
354
+ const data = readProfile(normalizedProfileName);
221
355
  if (data.plugins.length === 0) {
222
- p.log.warn(`No plugins to install in profile "${profileName}".`);
356
+ p.log.warn(`No plugins to install in profile "${normalizedProfileName}".`);
223
357
  return;
224
358
  }
225
359
  const s = p.spinner();
226
- s.start(`Installing ${data.plugins.length} plugin(s) from "${profileName}"...`);
360
+ s.start(`Installing ${data.plugins.length} plugin(s) from "${normalizedProfileName}"...`);
227
361
  let installed = 0;
228
362
  let failed = 0;
229
363
  for (const plugin of data.plugins) {
230
364
  s.message(`Installing ${plugin}...`);
231
365
  try {
232
- execSync(`claude plugin install ${plugin} --scope project`, {
366
+ execFileSync("claude", ["plugin", "install", plugin, "--scope", "project"], {
233
367
  stdio: "pipe",
234
368
  });
235
369
  installed++;
@@ -243,6 +377,11 @@ async function executeProfile(profileName) {
243
377
  p.log.success("Done.");
244
378
  }
245
379
  async function interactiveMode() {
380
+ if (!canPrompt()) {
381
+ printNonInteractiveHelp();
382
+ process.exitCode = 1;
383
+ return;
384
+ }
246
385
  p.intro("ccx — Agent Profile Manager");
247
386
  const action = await p.select({
248
387
  message: "What do you want to do?",
@@ -339,60 +478,116 @@ function printHelp() {
339
478
  console.log(`ccx — Agent Profile Manager for Claude Code
340
479
 
341
480
  Usage:
342
- ccx Interactive mode
343
- ccx <profile> Install all plugins from profile
344
- ccx add <name> Create a new profile
345
- ccx remove <name> Remove a profile
346
- ccx list List all profiles
481
+ ccx Interactive mode (TTY only)
482
+ ccx ui Interactive mode (TTY only)
483
+ ccx install <profile> Install all plugins from profile
484
+ ccx create <name> Create a new profile
485
+ ccx delete <name> Remove a profile
486
+ ccx profiles List all profiles
487
+ ccx add <profile> <plugin> Add plugin to profile
488
+ ccx remove <profile> <plugin> Remove plugin from profile
489
+ ccx list <profile> List plugins in profile
347
490
  ccx search <keyword> Search plugins in marketplaces
348
- ccx <profile> add [plugin] Add plugin to profile
349
- ccx <profile> remove [plugin] Remove plugin from profile
350
- ccx <profile> list List plugins in profile`);
491
+ ccx <profile> Install all plugins from profile
492
+ ccx <profile> add [plugin] Add plugin to profile (legacy)
493
+ ccx <profile> remove [plugin] Remove plugin from profile (legacy)
494
+ ccx <profile> list List plugins in profile (legacy)
495
+ ccx add <name> Create a new profile (legacy)
496
+ ccx remove <name> Remove a profile (legacy)
497
+ ccx list List all profiles (legacy)
498
+ ccx -v, --version Show version`);
351
499
  }
352
500
  // ── Main ──────────────────────────────────────────────────
353
- const args = process.argv.slice(2);
354
- if (args.length === 0) {
355
- interactiveMode();
356
- process.exit(0);
357
- }
358
- if (args[0] === "--help" || args[0] === "-h") {
359
- printHelp();
360
- process.exit(0);
361
- }
362
- const cmd = args[0];
363
- switch (cmd) {
364
- case "add":
365
- addProfile(args[1]);
366
- break;
367
- case "remove":
368
- removeProfile(args[1]);
369
- break;
370
- case "list":
371
- listProfiles();
372
- break;
373
- case "search":
374
- searchPlugins(args[1]);
375
- break;
376
- default: {
377
- const profileName = cmd;
378
- const sub = args[1];
379
- if (!sub) {
380
- executeProfile(profileName);
381
- }
382
- else if (sub === "add") {
383
- addPlugin(profileName, args[2]);
384
- }
385
- else if (sub === "remove") {
386
- removePlugin(profileName, args[2]);
387
- }
388
- else if (sub === "list") {
389
- listPlugins(profileName);
390
- }
391
- else {
392
- console.error(`Unknown command: ccx ${args.join(" ")}`);
393
- printHelp();
394
- process.exit(1);
501
+ async function main(args) {
502
+ if (args.length === 0) {
503
+ await interactiveMode();
504
+ return;
505
+ }
506
+ if (args[0] === "--help" || args[0] === "-h") {
507
+ printHelp();
508
+ return;
509
+ }
510
+ if (args[0] === "--version" || args[0] === "-v" || args[0] === "-V") {
511
+ const require = createRequire(import.meta.url);
512
+ const pkg = require("../package.json");
513
+ console.log(`ccx v${pkg.version}`);
514
+ return;
515
+ }
516
+ const cmd = args[0];
517
+ switch (cmd) {
518
+ case "ui":
519
+ case "tui":
520
+ case "interactive":
521
+ await interactiveMode();
522
+ break;
523
+ case "install":
524
+ if (!args[1]) {
525
+ missingArg("Profile name is required.", "ccx install <profile>");
526
+ return;
527
+ }
528
+ await executeProfile(args[1]);
529
+ break;
530
+ case "create":
531
+ await addProfile(args[1]);
532
+ break;
533
+ case "delete":
534
+ await removeProfile(args[1]);
535
+ break;
536
+ case "profiles":
537
+ await listProfiles();
538
+ break;
539
+ case "add":
540
+ if (args[2]) {
541
+ await addPlugin(args[1], args[2]);
542
+ }
543
+ else {
544
+ await addProfile(args[1]);
545
+ }
546
+ break;
547
+ case "remove":
548
+ if (args[2]) {
549
+ await removePlugin(args[1], args[2]);
550
+ }
551
+ else {
552
+ await removeProfile(args[1]);
553
+ }
554
+ break;
555
+ case "list":
556
+ if (args[1]) {
557
+ await listPlugins(args[1]);
558
+ }
559
+ else {
560
+ await listProfiles();
561
+ }
562
+ break;
563
+ case "search":
564
+ await searchPlugins(args[1]);
565
+ break;
566
+ default: {
567
+ const profileName = cmd;
568
+ const sub = args[1];
569
+ if (!sub) {
570
+ await executeProfile(profileName);
571
+ }
572
+ else if (sub === "add") {
573
+ await addPlugin(profileName, args[2]);
574
+ }
575
+ else if (sub === "remove") {
576
+ await removePlugin(profileName, args[2]);
577
+ }
578
+ else if (sub === "list") {
579
+ await listPlugins(profileName);
580
+ }
581
+ else {
582
+ console.error(`Unknown command: ccx ${args.join(" ")}`);
583
+ printHelp();
584
+ process.exitCode = 1;
585
+ }
586
+ break;
395
587
  }
396
- break;
397
588
  }
398
589
  }
590
+ main(process.argv.slice(2)).catch((err) => {
591
+ console.error(err instanceof Error ? err.message : String(err));
592
+ process.exitCode = 1;
593
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanmu/ccprofile",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
4
4
  "description": "Agent Profile Manager for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "tsc",
18
18
  "dev": "bun run src/index.ts",
19
+ "test": "pnpm run build && node test/cli.test.mjs",
19
20
  "prepublishOnly": "npm run build"
20
21
  },
21
22
  "dependencies": {