@guanmu/ccprofile 0.1.7 → 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 +273 -86
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import path from "node:path";
5
- import { execSync } from "node:child_process";
5
+ import { execFileSync } from "node:child_process";
6
6
  import * as p from "@clack/prompts";
7
7
  const PROFILES_DIR = path.join(process.env.HOME, ".ccx", "profiles");
8
8
  const MARKETPLACES_DIR = path.join(process.env.HOME, ".claude", "plugins", "marketplaces");
@@ -12,24 +12,77 @@ function ensureProfilesDir() {
12
12
  function profilePath(name) {
13
13
  return path.join(PROFILES_DIR, `${name}.json`);
14
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
+ }
15
40
  function readProfile(name) {
16
- const file = profilePath(name);
41
+ const normalized = normalizeProfileName(name);
42
+ if (!normalized)
43
+ process.exit(1);
44
+ const file = profilePath(normalized);
17
45
  if (!fs.existsSync(file)) {
18
- 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.`);
19
61
  process.exit(1);
20
62
  }
21
- 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
+ };
22
72
  }
23
73
  function writeProfile(name, data) {
74
+ const normalized = normalizeProfileName(name);
75
+ if (!normalized)
76
+ return;
24
77
  ensureProfilesDir();
25
- fs.writeFileSync(profilePath(name), JSON.stringify(data, null, 2) + "\n");
78
+ fs.writeFileSync(profilePath(normalized), JSON.stringify({ ...data, name: normalized }, null, 2) + "\n");
26
79
  }
27
80
  function getProfileNames() {
28
81
  ensureProfilesDir();
29
82
  return fs
30
83
  .readdirSync(PROFILES_DIR)
31
84
  .filter((f) => f.endsWith(".json"))
32
- .map((f) => f.replace(".json", ""));
85
+ .map((f) => f.slice(0, -".json".length));
33
86
  }
34
87
  function getAllPlugins() {
35
88
  if (!fs.existsSync(MARKETPLACES_DIR))
@@ -40,27 +93,69 @@ function getAllPlugins() {
40
93
  const file = path.join(MARKETPLACES_DIR, dir, ".claude-plugin", "marketplace.json");
41
94
  if (!fs.existsSync(file))
42
95
  continue;
43
- const data = JSON.parse(fs.readFileSync(file, "utf-8"));
44
- 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;
45
121
  plugins.push({
46
- name: pl.name,
47
- description: pl.description || "",
48
- category: pl.category,
49
- marketplace: data.name,
122
+ name: name.trim(),
123
+ description: typeof description === "string" ? description : "",
124
+ category: typeof category === "string" ? category : undefined,
125
+ marketplace,
50
126
  });
51
127
  }
52
128
  }
53
129
  return plugins;
54
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
+ }
55
143
  // ── Commands ──────────────────────────────────────────────
56
144
  async function addProfile(name) {
57
145
  if (!name) {
146
+ if (!canPrompt()) {
147
+ missingArg("Profile name is required.", "ccx create <name>");
148
+ return;
149
+ }
58
150
  name = (await p.text({
59
151
  message: "Profile name:",
60
152
  }));
61
153
  if (p.isCancel(name))
62
154
  return;
63
155
  }
156
+ name = normalizeProfileName(name);
157
+ if (!name)
158
+ return;
64
159
  const file = profilePath(name);
65
160
  if (fs.existsSync(file)) {
66
161
  p.log.error(`Profile "${name}" already exists.`);
@@ -70,12 +165,20 @@ async function addProfile(name) {
70
165
  p.log.success(`Created profile "${name}".`);
71
166
  }
72
167
  async function removeProfile(name) {
168
+ if (!name && !canPrompt()) {
169
+ missingArg("Profile name is required.", "ccx delete <name>");
170
+ return;
171
+ }
73
172
  const names = getProfileNames();
74
173
  if (names.length === 0) {
75
174
  p.log.warn("No profiles found.");
76
175
  return;
77
176
  }
78
177
  if (!name) {
178
+ if (!canPrompt()) {
179
+ missingArg("Profile name is required.", "ccx delete <name>");
180
+ return;
181
+ }
79
182
  name = (await p.select({
80
183
  message: "Select profile to remove:",
81
184
  options: names.map((n) => ({ value: n, label: n })),
@@ -83,6 +186,9 @@ async function removeProfile(name) {
83
186
  if (p.isCancel(name))
84
187
  return;
85
188
  }
189
+ name = normalizeProfileName(name);
190
+ if (!name)
191
+ return;
86
192
  const file = profilePath(name);
87
193
  if (!fs.existsSync(file)) {
88
194
  p.log.error(`Profile "${name}" not found.`);
@@ -92,22 +198,26 @@ async function removeProfile(name) {
92
198
  p.log.success(`Removed profile "${name}".`);
93
199
  }
94
200
  async function listProfiles() {
95
- ensureProfilesDir();
96
- const files = fs
97
- .readdirSync(PROFILES_DIR)
98
- .filter((f) => f.endsWith(".json"));
99
- if (files.length === 0) {
201
+ const names = getProfileNames();
202
+ if (names.length === 0) {
100
203
  p.log.warn("No profiles found.");
101
204
  return;
102
205
  }
103
- for (const f of files) {
104
- const data = JSON.parse(fs.readFileSync(path.join(PROFILES_DIR, f), "utf-8"));
206
+ for (const name of names) {
207
+ const data = readProfile(name);
105
208
  p.log.success(`${data.name} (${data.plugins.length} plugins)`);
106
209
  }
107
210
  }
108
211
  async function addPlugin(profileName, plugin) {
212
+ const normalizedProfileName = normalizeProfileName(profileName);
213
+ if (!normalizedProfileName)
214
+ return;
109
215
  const data = readProfile(profileName);
110
216
  if (!plugin) {
217
+ if (!canPrompt()) {
218
+ missingArg("Plugin name is required.", "ccx add <profile> <plugin>");
219
+ return;
220
+ }
111
221
  const allPlugins = getAllPlugins();
112
222
  const available = allPlugins.filter((pl) => !data.plugins.includes(pl.name));
113
223
  if (available.length === 0) {
@@ -138,21 +248,31 @@ async function addPlugin(profileName, plugin) {
138
248
  plugin = selected;
139
249
  }
140
250
  }
251
+ plugin = normalizePluginName(plugin);
252
+ if (!plugin)
253
+ return;
141
254
  if (data.plugins.includes(plugin)) {
142
255
  p.log.warn(`Plugin "${plugin}" already in profile.`);
143
256
  return;
144
257
  }
145
258
  data.plugins.push(plugin);
146
- writeProfile(profileName, data);
147
- p.log.success(`Added "${plugin}" to profile "${profileName}".`);
259
+ writeProfile(normalizedProfileName, data);
260
+ p.log.success(`Added "${plugin}" to profile "${normalizedProfileName}".`);
148
261
  }
149
262
  async function removePlugin(profileName, plugin) {
263
+ const normalizedProfileName = normalizeProfileName(profileName);
264
+ if (!normalizedProfileName)
265
+ return;
150
266
  const data = readProfile(profileName);
151
267
  if (data.plugins.length === 0) {
152
268
  p.log.warn("No plugins in this profile.");
153
269
  return;
154
270
  }
155
271
  if (!plugin) {
272
+ if (!canPrompt()) {
273
+ missingArg("Plugin name is required.", "ccx remove <profile> <plugin>");
274
+ return;
275
+ }
156
276
  plugin = (await p.select({
157
277
  message: `Remove plugin from "${profileName}":`,
158
278
  options: data.plugins.map((pl) => ({
@@ -163,19 +283,25 @@ async function removePlugin(profileName, plugin) {
163
283
  if (p.isCancel(plugin))
164
284
  return;
165
285
  }
286
+ plugin = normalizePluginName(plugin);
287
+ if (!plugin)
288
+ return;
166
289
  const idx = data.plugins.indexOf(plugin);
167
290
  if (idx === -1) {
168
291
  p.log.error(`Plugin "${plugin}" not found.`);
169
292
  return;
170
293
  }
171
294
  data.plugins.splice(idx, 1);
172
- writeProfile(profileName, data);
173
- p.log.success(`Removed "${plugin}" from profile "${profileName}".`);
295
+ writeProfile(normalizedProfileName, data);
296
+ p.log.success(`Removed "${plugin}" from profile "${normalizedProfileName}".`);
174
297
  }
175
298
  async function listPlugins(profileName) {
176
- const data = readProfile(profileName);
299
+ const normalizedProfileName = normalizeProfileName(profileName);
300
+ if (!normalizedProfileName)
301
+ return;
302
+ const data = readProfile(normalizedProfileName);
177
303
  if (data.plugins.length === 0) {
178
- p.log.warn(`No plugins in profile "${profileName}".`);
304
+ p.log.warn(`No plugins in profile "${normalizedProfileName}".`);
179
305
  return;
180
306
  }
181
307
  for (const pl of data.plugins) {
@@ -184,6 +310,10 @@ async function listPlugins(profileName) {
184
310
  }
185
311
  async function searchPlugins(keyword) {
186
312
  if (!keyword) {
313
+ if (!canPrompt()) {
314
+ missingArg("Search keyword is required.", "ccx search <keyword>");
315
+ return;
316
+ }
187
317
  keyword = (await p.text({
188
318
  message: "Search plugins:",
189
319
  }));
@@ -218,19 +348,22 @@ async function searchPlugins(keyword) {
218
348
  }
219
349
  }
220
350
  async function executeProfile(profileName) {
221
- const data = readProfile(profileName);
351
+ const normalizedProfileName = normalizeProfileName(profileName);
352
+ if (!normalizedProfileName)
353
+ return;
354
+ const data = readProfile(normalizedProfileName);
222
355
  if (data.plugins.length === 0) {
223
- p.log.warn(`No plugins to install in profile "${profileName}".`);
356
+ p.log.warn(`No plugins to install in profile "${normalizedProfileName}".`);
224
357
  return;
225
358
  }
226
359
  const s = p.spinner();
227
- s.start(`Installing ${data.plugins.length} plugin(s) from "${profileName}"...`);
360
+ s.start(`Installing ${data.plugins.length} plugin(s) from "${normalizedProfileName}"...`);
228
361
  let installed = 0;
229
362
  let failed = 0;
230
363
  for (const plugin of data.plugins) {
231
364
  s.message(`Installing ${plugin}...`);
232
365
  try {
233
- execSync(`claude plugin install ${plugin} --scope project`, {
366
+ execFileSync("claude", ["plugin", "install", plugin, "--scope", "project"], {
234
367
  stdio: "pipe",
235
368
  });
236
369
  installed++;
@@ -244,6 +377,11 @@ async function executeProfile(profileName) {
244
377
  p.log.success("Done.");
245
378
  }
246
379
  async function interactiveMode() {
380
+ if (!canPrompt()) {
381
+ printNonInteractiveHelp();
382
+ process.exitCode = 1;
383
+ return;
384
+ }
247
385
  p.intro("ccx — Agent Profile Manager");
248
386
  const action = await p.select({
249
387
  message: "What do you want to do?",
@@ -340,67 +478,116 @@ function printHelp() {
340
478
  console.log(`ccx — Agent Profile Manager for Claude Code
341
479
 
342
480
  Usage:
343
- ccx Interactive mode
344
- ccx <profile> Install all plugins from profile
345
- ccx add <name> Create a new profile
346
- ccx remove <name> Remove a profile
347
- 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
348
490
  ccx search <keyword> Search plugins in marketplaces
349
- ccx <profile> add [plugin] Add plugin to profile
350
- ccx <profile> remove [plugin] Remove plugin from profile
351
- 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)
352
498
  ccx -v, --version Show version`);
353
499
  }
354
500
  // ── Main ──────────────────────────────────────────────────
355
- const args = process.argv.slice(2);
356
- if (args.length === 0) {
357
- interactiveMode();
358
- process.exit(0);
359
- }
360
- if (args[0] === "--help" || args[0] === "-h") {
361
- printHelp();
362
- process.exit(0);
363
- }
364
- if (args[0] === "--version" || args[0] === "-v" || args[0] === "-V") {
365
- const require = createRequire(import.meta.url);
366
- const pkg = require("../package.json");
367
- console.log(`ccx v${pkg.version}`);
368
- process.exit(0);
369
- }
370
- const cmd = args[0];
371
- switch (cmd) {
372
- case "add":
373
- addProfile(args[1]);
374
- break;
375
- case "remove":
376
- removeProfile(args[1]);
377
- break;
378
- case "list":
379
- listProfiles();
380
- break;
381
- case "search":
382
- searchPlugins(args[1]);
383
- break;
384
- default: {
385
- const profileName = cmd;
386
- const sub = args[1];
387
- if (!sub) {
388
- executeProfile(profileName);
389
- }
390
- else if (sub === "add") {
391
- addPlugin(profileName, args[2]);
392
- }
393
- else if (sub === "remove") {
394
- removePlugin(profileName, args[2]);
395
- }
396
- else if (sub === "list") {
397
- listPlugins(profileName);
398
- }
399
- else {
400
- console.error(`Unknown command: ccx ${args.join(" ")}`);
401
- printHelp();
402
- 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;
403
587
  }
404
- break;
405
588
  }
406
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.7",
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": {