@leynier/ccst 0.2.1 → 0.3.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/package.json CHANGED
@@ -1,58 +1,59 @@
1
1
  {
2
- "name": "@leynier/ccst",
3
- "version": "0.2.1",
4
- "description": "Claude Code Switch Tools for managing contexts",
5
- "keywords": [
6
- "claude",
7
- "claude-code",
8
- "cli",
9
- "contexts",
10
- "config",
11
- "switcher"
12
- ],
13
- "homepage": "https://github.com/leynier/ccst",
14
- "repository": {
15
- "type": "git",
16
- "url": "https://github.com/leynier/ccst.git"
17
- },
18
- "bugs": {
19
- "url": "https://github.com/leynier/ccst/issues"
20
- },
21
- "license": "MIT",
22
- "readme": "README.md",
23
- "module": "index.ts",
24
- "type": "module",
25
- "bin": {
26
- "ccst": "./src/index.ts"
27
- },
28
- "scripts": {
29
- "format": "biome format --write",
30
- "lint": "biome check --write",
31
- "validate": "bun run format && bun run lint"
32
- },
33
- "dependencies": {
34
- "commander": "^12.1.0",
35
- "picocolors": "^1.1.0"
36
- },
37
- "devDependencies": {
38
- "@biomejs/biome": "2.3.11",
39
- "@types/bun": "latest"
40
- },
41
- "peerDependencies": {
42
- "typescript": "^5"
43
- },
44
- "files": [
45
- "src",
46
- "index.ts",
47
- "README.md",
48
- "LICENSE",
49
- "AGENTS.md",
50
- "CLAUDE.md",
51
- "GEMINI.md",
52
- "biome.json",
53
- "tsconfig.json"
54
- ],
55
- "publishConfig": {
56
- "access": "public"
57
- }
2
+ "name": "@leynier/ccst",
3
+ "version": "0.3.0",
4
+ "description": "Claude Code Switch Tools for managing contexts",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "cli",
9
+ "contexts",
10
+ "config",
11
+ "switcher"
12
+ ],
13
+ "homepage": "https://github.com/leynier/ccst",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/leynier/ccst.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/leynier/ccst/issues"
20
+ },
21
+ "license": "MIT",
22
+ "readme": "README.md",
23
+ "module": "index.ts",
24
+ "type": "module",
25
+ "bin": {
26
+ "ccst": "./src/index.ts"
27
+ },
28
+ "scripts": {
29
+ "format": "biome format --write",
30
+ "lint": "biome check --write",
31
+ "validate": "bun run format && bun run lint"
32
+ },
33
+ "dependencies": {
34
+ "commander": "^12.1.0",
35
+ "jszip": "^3.10.1",
36
+ "picocolors": "^1.1.0"
37
+ },
38
+ "devDependencies": {
39
+ "@biomejs/biome": "2.3.11",
40
+ "@types/bun": "latest"
41
+ },
42
+ "peerDependencies": {
43
+ "typescript": "^5"
44
+ },
45
+ "files": [
46
+ "src",
47
+ "index.ts",
48
+ "README.md",
49
+ "LICENSE",
50
+ "AGENTS.md",
51
+ "CLAUDE.md",
52
+ "GEMINI.md",
53
+ "biome.json",
54
+ "tsconfig.json"
55
+ ],
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
58
59
  }
@@ -1,49 +1,55 @@
1
1
  export const completionsCommand = (shell: string | undefined): void => {
2
- if (!shell) {
3
- throw new Error("error: shell required for completions");
4
- }
5
- const available = ["bash", "zsh", "fish", "powershell", "elvish"];
6
- if (!available.includes(shell)) {
7
- throw new Error(`error: unsupported shell ${shell}`);
8
- }
9
- const options = [
10
- "-d",
11
- "--delete",
12
- "-c",
13
- "--current",
14
- "-r",
15
- "--rename",
16
- "-n",
17
- "--new",
18
- "-e",
19
- "--edit",
20
- "-s",
21
- "--show",
22
- "--export",
23
- "--import",
24
- "-u",
25
- "--unset",
26
- "--completions",
27
- "-q",
28
- "--quiet",
29
- "--in-project",
30
- "--local",
31
- "--merge-from",
32
- "--unmerge",
33
- "--merge-history",
34
- "--merge-full",
35
- ].join(" ");
36
- if (shell === "bash") {
37
- process.stdout.write(`# ccst bash completions\ncomplete -W "${options}" ccst\n`);
38
- return;
39
- }
40
- if (shell === "zsh") {
41
- process.stdout.write(`#compdef ccst\n_arguments '*::options:(${options})'\n`);
42
- return;
43
- }
44
- if (shell === "fish") {
45
- process.stdout.write(`complete -c ccst -f -a "${options}"\n`);
46
- return;
47
- }
48
- process.stdout.write(`# ${shell} completions not implemented; use bash/zsh/fish output\n`);
2
+ if (!shell) {
3
+ throw new Error("error: shell required for completions");
4
+ }
5
+ const available = ["bash", "zsh", "fish", "powershell", "elvish"];
6
+ if (!available.includes(shell)) {
7
+ throw new Error(`error: unsupported shell ${shell}`);
8
+ }
9
+ const options = [
10
+ "-d",
11
+ "--delete",
12
+ "-c",
13
+ "--current",
14
+ "-r",
15
+ "--rename",
16
+ "-n",
17
+ "--new",
18
+ "-e",
19
+ "--edit",
20
+ "-s",
21
+ "--show",
22
+ "--export",
23
+ "--import",
24
+ "-u",
25
+ "--unset",
26
+ "--completions",
27
+ "-q",
28
+ "--quiet",
29
+ "--in-project",
30
+ "--local",
31
+ "--merge-from",
32
+ "--unmerge",
33
+ "--merge-history",
34
+ "--merge-full",
35
+ ].join(" ");
36
+ if (shell === "bash") {
37
+ process.stdout.write(
38
+ `# ccst bash completions\ncomplete -W "${options}" ccst\n`,
39
+ );
40
+ return;
41
+ }
42
+ if (shell === "zsh") {
43
+ process.stdout.write(
44
+ `#compdef ccst\n_arguments '*::options:(${options})'\n`,
45
+ );
46
+ return;
47
+ }
48
+ if (shell === "fish") {
49
+ process.stdout.write(`complete -c ccst -f -a "${options}"\n`);
50
+ return;
51
+ }
52
+ process.stdout.write(
53
+ `# ${shell} completions not implemented; use bash/zsh/fish output\n`,
54
+ );
49
55
  };
@@ -0,0 +1,40 @@
1
+ import { resolve } from "node:path";
2
+ import JSZip from "jszip";
3
+ import pc from "picocolors";
4
+ import {
5
+ getAllBackupFilePaths,
6
+ getRelativePath,
7
+ } from "../../utils/ccs-paths.js";
8
+
9
+ const DEFAULT_OUTPUT = "ccs-config.zip";
10
+
11
+ export const configDumpCommand = async (outputPath?: string): Promise<void> => {
12
+ const output = resolve(outputPath ?? DEFAULT_OUTPUT);
13
+ const files = getAllBackupFilePaths();
14
+ if (files.length === 0) {
15
+ console.log(pc.yellow("No CCS configuration files found to backup"));
16
+ return;
17
+ }
18
+ const zip = new JSZip();
19
+ for (const filePath of files) {
20
+ try {
21
+ const relativePath = getRelativePath(filePath);
22
+ const content = await Bun.file(filePath).arrayBuffer();
23
+ zip.file(relativePath, content);
24
+ } catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ throw new Error(`Failed to read ${filePath}: ${message}`);
27
+ }
28
+ }
29
+ try {
30
+ const zipContent = await zip.generateAsync({ type: "uint8array" });
31
+ await Bun.write(output, zipContent);
32
+ } catch (error) {
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ throw new Error(`Failed to write ${output}: ${message}`);
35
+ }
36
+ console.log(pc.green(`Exported ${files.length} files to ${output}`));
37
+ for (const filePath of files) {
38
+ console.log(pc.dim(` - ${getRelativePath(filePath)}`));
39
+ }
40
+ };
@@ -0,0 +1,122 @@
1
+ import { existsSync, unlinkSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import JSZip from "jszip";
4
+ import pc from "picocolors";
5
+ import {
6
+ ensureDirectoryExists,
7
+ getCcsHome,
8
+ getFilesToDelete,
9
+ } from "../../utils/ccs-paths.js";
10
+
11
+ const DEFAULT_INPUT = "ccs-config.zip";
12
+
13
+ type LoadOptions = {
14
+ replace?: boolean;
15
+ yes?: boolean;
16
+ };
17
+
18
+ const confirmReplace = async (
19
+ profiles: string[],
20
+ authFiles: string[],
21
+ ): Promise<boolean> => {
22
+ const totalFiles = profiles.length + authFiles.length;
23
+ if (totalFiles === 0) {
24
+ return true;
25
+ }
26
+ console.log(pc.yellow("\nThe following files will be deleted:"));
27
+ if (profiles.length > 0) {
28
+ console.log(pc.dim("\nProfiles:"));
29
+ for (const file of profiles) {
30
+ console.log(pc.red(` - ${file}`));
31
+ }
32
+ }
33
+ if (authFiles.length > 0) {
34
+ console.log(pc.dim("\nAuth files:"));
35
+ for (const file of authFiles) {
36
+ console.log(pc.red(` - ${file}`));
37
+ }
38
+ }
39
+ console.log();
40
+ // Dynamic import for interactive prompt
41
+ const readline = await import("node:readline");
42
+ const rl = readline.createInterface({
43
+ input: process.stdin,
44
+ output: process.stdout,
45
+ });
46
+ return new Promise((resolvePromise) => {
47
+ rl.question(
48
+ pc.yellow(`Delete ${totalFiles} files and replace? [y/N] `),
49
+ (answer) => {
50
+ rl.close();
51
+ resolvePromise(answer.toLowerCase() === "y");
52
+ },
53
+ );
54
+ });
55
+ };
56
+
57
+ export const configLoadCommand = async (
58
+ inputPath?: string,
59
+ options?: LoadOptions,
60
+ ): Promise<void> => {
61
+ const input = resolve(inputPath ?? DEFAULT_INPUT);
62
+ if (!existsSync(input)) {
63
+ throw new Error(`File not found: ${input}`);
64
+ }
65
+ const zipData = await Bun.file(input).arrayBuffer();
66
+ const zip = await JSZip.loadAsync(zipData);
67
+ const fileEntries = Object.keys(zip.files).filter((name) => {
68
+ const file = zip.files[name];
69
+ return file && !file.dir;
70
+ });
71
+ if (fileEntries.length === 0) {
72
+ console.log(pc.yellow("No files found in zip archive"));
73
+ return;
74
+ }
75
+ const ccsHome = getCcsHome();
76
+ // Handle replace mode
77
+ if (options?.replace) {
78
+ const { profiles, authFiles } = getFilesToDelete();
79
+ if (!options.yes) {
80
+ const confirmed = await confirmReplace(profiles, authFiles);
81
+ if (!confirmed) {
82
+ console.log(pc.dim("Operation cancelled"));
83
+ return;
84
+ }
85
+ }
86
+ // Delete existing files
87
+ for (const file of [...profiles, ...authFiles]) {
88
+ if (existsSync(file)) {
89
+ unlinkSync(file);
90
+ }
91
+ }
92
+ if (profiles.length + authFiles.length > 0) {
93
+ console.log(
94
+ pc.dim(`Deleted ${profiles.length + authFiles.length} existing files`),
95
+ );
96
+ }
97
+ }
98
+ // Extract files
99
+ let extractedCount = 0;
100
+ for (const relativePath of fileEntries) {
101
+ // Handle both forward and back slashes for cross-platform compatibility
102
+ const pathParts = relativePath.split(/[/\\]/);
103
+ const absolutePath = join(ccsHome, ...pathParts);
104
+ // Validate path is within ccsHome (prevent path traversal attacks)
105
+ if (!absolutePath.startsWith(ccsHome)) {
106
+ console.warn(pc.yellow(`Skipping suspicious path: ${relativePath}`));
107
+ continue;
108
+ }
109
+ const zipFile = zip.files[relativePath];
110
+ if (!zipFile) {
111
+ continue;
112
+ }
113
+ const fileData = await zipFile.async("uint8array");
114
+ ensureDirectoryExists(absolutePath);
115
+ await Bun.write(absolutePath, fileData);
116
+ extractedCount++;
117
+ }
118
+ console.log(pc.green(`Imported ${extractedCount} files from ${input}`));
119
+ for (const relativePath of fileEntries) {
120
+ console.log(pc.dim(` - ${relativePath}`));
121
+ }
122
+ };
@@ -1,5 +1,8 @@
1
1
  import type { ContextManager } from "../core/context-manager.js";
2
2
 
3
- export const createCommand = async (manager: ContextManager, name: string): Promise<void> => {
4
- await manager.createContext(name);
3
+ export const createCommand = async (
4
+ manager: ContextManager,
5
+ name: string,
6
+ ): Promise<void> => {
7
+ await manager.createContext(name);
5
8
  };
@@ -1,9 +1,12 @@
1
1
  import type { ContextManager } from "../core/context-manager.js";
2
2
 
3
- export const deleteCommand = async (manager: ContextManager, name?: string): Promise<void> => {
4
- if (name) {
5
- await manager.deleteContext(name);
6
- return;
7
- }
8
- await manager.interactiveDelete();
3
+ export const deleteCommand = async (
4
+ manager: ContextManager,
5
+ name?: string,
6
+ ): Promise<void> => {
7
+ if (name) {
8
+ await manager.deleteContext(name);
9
+ return;
10
+ }
11
+ await manager.interactiveDelete();
9
12
  };
@@ -1,13 +1,16 @@
1
1
  import type { ContextManager } from "../core/context-manager.js";
2
2
 
3
- export const editCommand = async (manager: ContextManager, name?: string): Promise<void> => {
4
- if (name) {
5
- await manager.editContext(name);
6
- return;
7
- }
8
- const current = await manager.getCurrentContext();
9
- if (!current) {
10
- throw new Error("error: no current context set");
11
- }
12
- await manager.editContext(current);
3
+ export const editCommand = async (
4
+ manager: ContextManager,
5
+ name?: string,
6
+ ): Promise<void> => {
7
+ if (name) {
8
+ await manager.editContext(name);
9
+ return;
10
+ }
11
+ const current = await manager.getCurrentContext();
12
+ if (!current) {
13
+ throw new Error("error: no current context set");
14
+ }
15
+ await manager.editContext(current);
13
16
  };
@@ -1,13 +1,16 @@
1
1
  import type { ContextManager } from "../core/context-manager.js";
2
2
 
3
- export const exportCommand = async (manager: ContextManager, name?: string): Promise<void> => {
4
- if (name) {
5
- await manager.exportContext(name);
6
- return;
7
- }
8
- const current = await manager.getCurrentContext();
9
- if (!current) {
10
- throw new Error("error: no current context set");
11
- }
12
- await manager.exportContext(current);
3
+ export const exportCommand = async (
4
+ manager: ContextManager,
5
+ name?: string,
6
+ ): Promise<void> => {
7
+ if (name) {
8
+ await manager.exportContext(name);
9
+ return;
10
+ }
11
+ const current = await manager.getCurrentContext();
12
+ if (!current) {
13
+ throw new Error("error: no current context set");
14
+ }
15
+ await manager.exportContext(current);
13
16
  };
@@ -1,88 +1,113 @@
1
- import path from "node:path";
2
- import { homedir } from "node:os";
3
1
  import { existsSync, mkdirSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
4
  import type { ContextManager } from "../../core/context-manager.js";
5
+ import { colors } from "../../utils/colors.js";
5
6
  import { deepMerge } from "../../utils/deep-merge.js";
6
7
  import { readJson, readJsonIfExists } from "../../utils/json.js";
7
- import { colors } from "../../utils/colors.js";
8
8
 
9
9
  const defaultConfigsDir = (): string => path.join(homedir(), ".ccst");
10
10
  const ccsDir = (): string => path.join(homedir(), ".ccs");
11
11
 
12
- const ensureDefaultConfig = async (manager: ContextManager, configsDir: string): Promise<{ created: boolean; defaultPath: string }> => {
13
- const defaultPath = path.join(configsDir, "default.json");
14
- if (existsSync(defaultPath)) {
15
- return { created: false, defaultPath };
16
- }
17
- const currentContext = await getCurrentContext(manager);
18
- if (currentContext) {
19
- return { created: false, defaultPath };
20
- }
21
- const claudeSettings = path.join(homedir(), ".claude", "settings.json");
22
- if (!existsSync(claudeSettings)) {
23
- return { created: false, defaultPath };
24
- }
25
- mkdirSync(configsDir, { recursive: true });
26
- const content = await Bun.file(claudeSettings).text();
27
- await Bun.write(defaultPath, content);
28
- return { created: true, defaultPath };
12
+ const ensureDefaultConfig = async (
13
+ manager: ContextManager,
14
+ configsDir: string,
15
+ ): Promise<{ created: boolean; defaultPath: string }> => {
16
+ const defaultPath = path.join(configsDir, "default.json");
17
+ if (existsSync(defaultPath)) {
18
+ return { created: false, defaultPath };
19
+ }
20
+ const currentContext = await getCurrentContext(manager);
21
+ if (currentContext) {
22
+ return { created: false, defaultPath };
23
+ }
24
+ const claudeSettings = path.join(homedir(), ".claude", "settings.json");
25
+ if (!existsSync(claudeSettings)) {
26
+ return { created: false, defaultPath };
27
+ }
28
+ mkdirSync(configsDir, { recursive: true });
29
+ const content = await Bun.file(claudeSettings).text();
30
+ await Bun.write(defaultPath, content);
31
+ return { created: true, defaultPath };
29
32
  };
30
33
 
31
- const getCurrentContext = async (manager: ContextManager): Promise<string | undefined> => {
32
- return manager.getCurrentContext();
34
+ const getCurrentContext = async (
35
+ manager: ContextManager,
36
+ ): Promise<string | undefined> => {
37
+ return manager.getCurrentContext();
33
38
  };
34
39
 
35
- const loadDefaultConfig = async (configsDir: string): Promise<Record<string, unknown>> => {
36
- const defaultPath = path.join(configsDir, "default.json");
37
- return readJsonIfExists<Record<string, unknown>>(defaultPath, {});
40
+ const loadDefaultConfig = async (
41
+ configsDir: string,
42
+ ): Promise<Record<string, unknown>> => {
43
+ const defaultPath = path.join(configsDir, "default.json");
44
+ return readJsonIfExists<Record<string, unknown>>(defaultPath, {});
38
45
  };
39
46
 
40
- const importProfile = async (manager: ContextManager, profileName: string, merged: Record<string, unknown>): Promise<void> => {
41
- await manager.deleteContext(profileName).catch(() => undefined);
42
- const input = `${JSON.stringify(merged, null, 2)}\n`;
43
- await manager.importContextFromString(profileName, input);
47
+ const importProfile = async (
48
+ manager: ContextManager,
49
+ profileName: string,
50
+ merged: Record<string, unknown>,
51
+ ): Promise<void> => {
52
+ await manager.deleteContext(profileName).catch(() => undefined);
53
+ const input = `${JSON.stringify(merged, null, 2)}\n`;
54
+ await manager.importContextFromString(profileName, input);
44
55
  };
45
56
 
46
- const createDefaultProfileIfNeeded = async (manager: ContextManager, created: boolean, defaultProfile: Record<string, unknown>): Promise<void> => {
47
- if (!created) {
48
- return;
49
- }
50
- await importProfile(manager, "default", defaultProfile);
57
+ const createDefaultProfileIfNeeded = async (
58
+ manager: ContextManager,
59
+ created: boolean,
60
+ defaultProfile: Record<string, unknown>,
61
+ ): Promise<void> => {
62
+ if (!created) {
63
+ return;
64
+ }
65
+ await importProfile(manager, "default", defaultProfile);
51
66
  };
52
67
 
53
- export const importFromCcs = async (manager: ContextManager, configsDir?: string): Promise<void> => {
54
- const ccsPath = ccsDir();
55
- if (!existsSync(ccsPath)) {
56
- throw new Error(`CCS directory not found: ${ccsPath}`);
57
- }
58
- console.log(`📥 Importing profiles from CCS settings...`);
59
- const dir = configsDir ?? defaultConfigsDir();
60
- const { created } = await ensureDefaultConfig(manager, dir);
61
- const defaultConfig = await loadDefaultConfig(dir);
62
- const defaultProfile = await readJsonIfExists<Record<string, unknown>>(path.join(dir, "default.json"), defaultConfig);
63
- await createDefaultProfileIfNeeded(manager, created, defaultProfile);
64
- const currentContext = await manager.getCurrentContext();
65
- let entries: string[] = [];
66
- try {
67
- entries = readdirSync(ccsPath).filter((name) => name.endsWith(".settings.json"));
68
- } catch {
69
- entries = [];
70
- }
71
- let importedCount = 0;
72
- for (const fileName of entries) {
73
- const settingsPath = path.join(ccsPath, fileName);
74
- const profileName = fileName.replace(/\.settings\.json$/u, "");
75
- console.log(` 📦 Importing '${colors.cyan(profileName)}'...`);
76
- const settings = await readJson<Record<string, unknown>>(settingsPath);
77
- const merged = deepMerge(defaultConfig, settings);
78
- if (currentContext && currentContext === profileName) {
79
- await manager.unsetContext();
80
- }
81
- await importProfile(manager, profileName, merged);
82
- importedCount++;
83
- }
84
- if (currentContext) {
85
- await manager.switchContext(currentContext);
86
- }
87
- console.log(`✅ Imported ${colors.bold(colors.green(String(importedCount)))} profiles from CCS`);
68
+ export const importFromCcs = async (
69
+ manager: ContextManager,
70
+ configsDir?: string,
71
+ ): Promise<void> => {
72
+ const ccsPath = ccsDir();
73
+ if (!existsSync(ccsPath)) {
74
+ throw new Error(`CCS directory not found: ${ccsPath}`);
75
+ }
76
+ console.log(`📥 Importing profiles from CCS settings...`);
77
+ const dir = configsDir ?? defaultConfigsDir();
78
+ const { created } = await ensureDefaultConfig(manager, dir);
79
+ const defaultConfig = await loadDefaultConfig(dir);
80
+ const defaultProfile = await readJsonIfExists<Record<string, unknown>>(
81
+ path.join(dir, "default.json"),
82
+ defaultConfig,
83
+ );
84
+ await createDefaultProfileIfNeeded(manager, created, defaultProfile);
85
+ const currentContext = await manager.getCurrentContext();
86
+ let entries: string[] = [];
87
+ try {
88
+ entries = readdirSync(ccsPath).filter((name) =>
89
+ name.endsWith(".settings.json"),
90
+ );
91
+ } catch {
92
+ entries = [];
93
+ }
94
+ let importedCount = 0;
95
+ for (const fileName of entries) {
96
+ const settingsPath = path.join(ccsPath, fileName);
97
+ const profileName = fileName.replace(/\.settings\.json$/u, "");
98
+ console.log(` 📦 Importing '${colors.cyan(profileName)}'...`);
99
+ const settings = await readJson<Record<string, unknown>>(settingsPath);
100
+ const merged = deepMerge(defaultConfig, settings);
101
+ if (currentContext && currentContext === profileName) {
102
+ await manager.unsetContext();
103
+ }
104
+ await importProfile(manager, profileName, merged);
105
+ importedCount++;
106
+ }
107
+ if (currentContext) {
108
+ await manager.switchContext(currentContext);
109
+ }
110
+ console.log(
111
+ `✅ Imported ${colors.bold(colors.green(String(importedCount)))} profiles from CCS`,
112
+ );
88
113
  };