@leynier/ccst 0.2.1 → 0.3.1

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/src/index.ts CHANGED
@@ -1,146 +1,185 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from "commander";
3
- import { getPaths } from "./utils/paths.js";
4
- import { ContextManager } from "./core/context-manager.js";
5
- import { resolveSettingsLevel } from "./core/settings-level.js";
6
- import { listCommand } from "./commands/list.js";
7
- import { switchCommand, switchPreviousCommand } from "./commands/switch.js";
3
+ import { completionsCommand } from "./commands/completions.js";
4
+ import { configDumpCommand } from "./commands/config/dump.js";
5
+ import { configLoadCommand } from "./commands/config/load.js";
8
6
  import { createCommand } from "./commands/create.js";
9
7
  import { deleteCommand } from "./commands/delete.js";
10
- import { renameCommand } from "./commands/rename.js";
11
8
  import { editCommand } from "./commands/edit.js";
12
- import { showCommand } from "./commands/show.js";
13
9
  import { exportCommand } from "./commands/export.js";
14
10
  import { importCommand } from "./commands/import.js";
15
- import { unsetCommand } from "./commands/unset.js";
16
- import { mergeCommand, mergeHistoryCommand, unmergeCommand } from "./commands/merge.js";
17
- import { completionsCommand } from "./commands/completions.js";
18
11
  import { importFromCcs } from "./commands/import-profiles/ccs.js";
19
12
  import { importFromConfigs } from "./commands/import-profiles/configs.js";
13
+ import { listCommand } from "./commands/list.js";
14
+ import {
15
+ mergeCommand,
16
+ mergeHistoryCommand,
17
+ unmergeCommand,
18
+ } from "./commands/merge.js";
19
+ import { renameCommand } from "./commands/rename.js";
20
+ import { showCommand } from "./commands/show.js";
21
+ import { switchCommand, switchPreviousCommand } from "./commands/switch.js";
22
+ import { unsetCommand } from "./commands/unset.js";
23
+ import { ContextManager } from "./core/context-manager.js";
24
+ import { resolveSettingsLevel } from "./core/settings-level.js";
25
+ import { getPaths } from "./utils/paths.js";
20
26
 
21
27
  export const program = new Command();
22
28
 
23
29
  const main = async (): Promise<void> => {
24
- program
25
- .name("ccst")
26
- .description("Claude Code Switch Tools")
27
- .argument("[context]", "context name")
28
- .option("-d, --delete", "delete context")
29
- .option("-c, --current", "print current context")
30
- .option("-r, --rename", "rename context")
31
- .option("-n, --new", "create new context")
32
- .option("-e, --edit", "edit context")
33
- .option("-s, --show", "show context")
34
- .option("--export", "export context to stdout")
35
- .option("--import", "import context from stdin")
36
- .option("-u, --unset", "unset current context")
37
- .option("--completions <shell>", "generate completions")
38
- .option("-q, --quiet", "show only current context")
39
- .option("--in-project", "use project settings level")
40
- .option("--local", "use local settings level")
41
- .option("--merge-from <source>", "merge permissions from source")
42
- .option("--unmerge <source>", "remove permissions merged from source")
43
- .option("--merge-history", "show merge history")
44
- .option("--merge-full", "merge full settings")
45
- .allowExcessArguments(false)
46
- .action(async (context: string | undefined, options: Record<string, unknown>) => {
47
- if (options.completions) {
48
- completionsCommand(options.completions as string);
49
- return;
50
- }
51
- const level = resolveSettingsLevel(options);
52
- const manager = new ContextManager(getPaths(level));
53
- if (options.current) {
54
- const current = await manager.getCurrentContext();
55
- if (current) {
56
- console.log(current);
57
- }
58
- return;
59
- }
60
- if (options.unset) {
61
- await unsetCommand(manager);
62
- return;
63
- }
64
- if (options.delete) {
65
- await deleteCommand(manager, context);
66
- return;
67
- }
68
- if (options.rename) {
69
- await renameCommand(manager, context);
70
- return;
71
- }
72
- if (options.new) {
73
- if (!context) {
74
- await manager.interactiveCreateContext();
75
- return;
76
- }
77
- await createCommand(manager, context);
78
- return;
79
- }
80
- if (options.edit) {
81
- await editCommand(manager, context);
82
- return;
83
- }
84
- if (options.show) {
85
- await showCommand(manager, context);
86
- return;
87
- }
88
- if (options.export) {
89
- await exportCommand(manager, context);
90
- return;
91
- }
92
- if (options.import) {
93
- await importCommand(manager, context);
94
- return;
95
- }
96
- if (options.mergeFrom) {
97
- await mergeCommand(manager, options.mergeFrom as string, context, options.mergeFull as boolean);
98
- return;
99
- }
100
- if (options.unmerge) {
101
- await unmergeCommand(manager, options.unmerge as string, context, options.mergeFull as boolean);
102
- return;
103
- }
104
- if (options.mergeHistory) {
105
- await mergeHistoryCommand(manager, context);
106
- return;
107
- }
108
- if (context === "-") {
109
- await switchPreviousCommand(manager);
110
- return;
111
- }
112
- if (context) {
113
- await switchCommand(manager, context);
114
- return;
115
- }
116
- if (process.env.CCTX_INTERACTIVE === "1") {
117
- await manager.interactiveSelect();
118
- return;
119
- }
120
- await listCommand(manager, options.quiet as boolean);
121
- });
122
- const importCommandGroup = program.command("import").description("import profiles");
123
- importCommandGroup
124
- .command("ccs")
125
- .description("import from CCS settings")
126
- .option("-d, --configs-dir <dir>", "configs directory")
127
- .action(async (options) => {
128
- const manager = new ContextManager(getPaths("user"));
129
- await importFromCcs(manager, options.configsDir);
130
- });
131
- importCommandGroup
132
- .command("configs")
133
- .description("import from configs directory")
134
- .option("-d, --configs-dir <dir>", "configs directory")
135
- .action(async (options) => {
136
- const manager = new ContextManager(getPaths("user"));
137
- await importFromConfigs(manager, options.configsDir);
138
- });
139
- try {
140
- await program.parseAsync(process.argv);
141
- } catch {
142
- return;
143
- }
30
+ program
31
+ .name("ccst")
32
+ .description("Claude Code Switch Tools")
33
+ .argument("[context]", "context name")
34
+ .option("-d, --delete", "delete context")
35
+ .option("-c, --current", "print current context")
36
+ .option("-r, --rename", "rename context")
37
+ .option("-n, --new", "create new context")
38
+ .option("-e, --edit", "edit context")
39
+ .option("-s, --show", "show context")
40
+ .option("--export", "export context to stdout")
41
+ .option("--import", "import context from stdin")
42
+ .option("-u, --unset", "unset current context")
43
+ .option("--completions <shell>", "generate completions")
44
+ .option("-q, --quiet", "show only current context")
45
+ .option("--in-project", "use project settings level")
46
+ .option("--local", "use local settings level")
47
+ .option("--merge-from <source>", "merge permissions from source")
48
+ .option("--unmerge <source>", "remove permissions merged from source")
49
+ .option("--merge-history", "show merge history")
50
+ .option("--merge-full", "merge full settings")
51
+ .allowExcessArguments(false)
52
+ .action(
53
+ async (context: string | undefined, options: Record<string, unknown>) => {
54
+ if (options.completions) {
55
+ completionsCommand(options.completions as string);
56
+ return;
57
+ }
58
+ const level = resolveSettingsLevel(options);
59
+ const manager = new ContextManager(getPaths(level));
60
+ if (options.current) {
61
+ const current = await manager.getCurrentContext();
62
+ if (current) {
63
+ console.log(current);
64
+ }
65
+ return;
66
+ }
67
+ if (options.unset) {
68
+ await unsetCommand(manager);
69
+ return;
70
+ }
71
+ if (options.delete) {
72
+ await deleteCommand(manager, context);
73
+ return;
74
+ }
75
+ if (options.rename) {
76
+ await renameCommand(manager, context);
77
+ return;
78
+ }
79
+ if (options.new) {
80
+ if (!context) {
81
+ await manager.interactiveCreateContext();
82
+ return;
83
+ }
84
+ await createCommand(manager, context);
85
+ return;
86
+ }
87
+ if (options.edit) {
88
+ await editCommand(manager, context);
89
+ return;
90
+ }
91
+ if (options.show) {
92
+ await showCommand(manager, context);
93
+ return;
94
+ }
95
+ if (options.export) {
96
+ await exportCommand(manager, context);
97
+ return;
98
+ }
99
+ if (options.import) {
100
+ await importCommand(manager, context);
101
+ return;
102
+ }
103
+ if (options.mergeFrom) {
104
+ await mergeCommand(
105
+ manager,
106
+ options.mergeFrom as string,
107
+ context,
108
+ options.mergeFull as boolean,
109
+ );
110
+ return;
111
+ }
112
+ if (options.unmerge) {
113
+ await unmergeCommand(
114
+ manager,
115
+ options.unmerge as string,
116
+ context,
117
+ options.mergeFull as boolean,
118
+ );
119
+ return;
120
+ }
121
+ if (options.mergeHistory) {
122
+ await mergeHistoryCommand(manager, context);
123
+ return;
124
+ }
125
+ if (context === "-") {
126
+ await switchPreviousCommand(manager);
127
+ return;
128
+ }
129
+ if (context) {
130
+ await switchCommand(manager, context);
131
+ return;
132
+ }
133
+ if (process.env.CCTX_INTERACTIVE === "1") {
134
+ await manager.interactiveSelect();
135
+ return;
136
+ }
137
+ await listCommand(manager, options.quiet as boolean);
138
+ },
139
+ );
140
+ const importCommandGroup = program
141
+ .command("import")
142
+ .description("import profiles");
143
+ importCommandGroup
144
+ .command("ccs")
145
+ .description("import from CCS settings")
146
+ .option("-d, --configs-dir <dir>", "configs directory")
147
+ .action(async (options) => {
148
+ const manager = new ContextManager(getPaths("user"));
149
+ await importFromCcs(manager, options.configsDir);
150
+ });
151
+ importCommandGroup
152
+ .command("configs")
153
+ .description("import from configs directory")
154
+ .option("-d, --configs-dir <dir>", "configs directory")
155
+ .action(async (options) => {
156
+ const manager = new ContextManager(getPaths("user"));
157
+ await importFromConfigs(manager, options.configsDir);
158
+ });
159
+ const configCommandGroup = program
160
+ .command("config")
161
+ .description("CCS config backup/restore");
162
+ configCommandGroup
163
+ .command("dump")
164
+ .description("export CCS config to zip")
165
+ .argument("[output]", "output path", "ccs-config.zip")
166
+ .action(async (output) => {
167
+ await configDumpCommand(output);
168
+ });
169
+ configCommandGroup
170
+ .command("load")
171
+ .description("import CCS config from zip")
172
+ .argument("[input]", "input path", "ccs-config.zip")
173
+ .option("-r, --replace", "replace all existing files")
174
+ .option("-y, --yes", "skip confirmation prompt")
175
+ .action(async (input, options) => {
176
+ await configLoadCommand(input, options);
177
+ });
178
+ try {
179
+ await program.parseAsync(process.argv);
180
+ } catch {
181
+ return;
182
+ }
144
183
  };
145
184
 
146
185
  await main();
@@ -1,14 +1,14 @@
1
1
  export type SettingsLevel = "user" | "project" | "local";
2
2
 
3
3
  export type State = {
4
- current?: string;
5
- previous?: string;
4
+ current?: string;
5
+ previous?: string;
6
6
  };
7
7
 
8
8
  export type MergeHistoryEntry = {
9
- source: string;
10
- mergedItems: string[];
11
- timestamp: string;
9
+ source: string;
10
+ mergedItems: string[];
11
+ timestamp: string;
12
12
  };
13
13
 
14
14
  export type MergeHistory = MergeHistoryEntry[];
@@ -0,0 +1,120 @@
1
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, relative } from "node:path";
4
+
5
+ // Cross-platform CCS home directory
6
+ export const getCcsHome = (): string => join(homedir(), ".ccs");
7
+
8
+ // File patterns to backup
9
+ export type CcsBackupFiles = {
10
+ config: string | null;
11
+ settingsProfiles: string[];
12
+ cliproxyAccounts: string | null;
13
+ cliproxyConfig: string | null;
14
+ cliproxyAuthFiles: string[];
15
+ };
16
+
17
+ // Helper to safely read directory contents
18
+ const safeReaddirSync = (dirPath: string): string[] => {
19
+ try {
20
+ return readdirSync(dirPath);
21
+ } catch {
22
+ return [];
23
+ }
24
+ };
25
+
26
+ // Get all files to backup from CCS directory
27
+ export const getCcsBackupFiles = (): CcsBackupFiles => {
28
+ const ccsHome = getCcsHome();
29
+ const result: CcsBackupFiles = {
30
+ config: null,
31
+ settingsProfiles: [],
32
+ cliproxyAccounts: null,
33
+ cliproxyConfig: null,
34
+ cliproxyAuthFiles: [],
35
+ };
36
+ if (!existsSync(ccsHome)) {
37
+ return result;
38
+ }
39
+ // config.yaml
40
+ const configPath = join(ccsHome, "config.yaml");
41
+ if (existsSync(configPath)) {
42
+ result.config = configPath;
43
+ }
44
+ // *.settings.json profiles
45
+ const rootFiles = safeReaddirSync(ccsHome);
46
+ for (const file of rootFiles) {
47
+ if (file.endsWith(".settings.json")) {
48
+ result.settingsProfiles.push(join(ccsHome, file));
49
+ }
50
+ }
51
+ // cliproxy files
52
+ const cliproxyDir = join(ccsHome, "cliproxy");
53
+ if (existsSync(cliproxyDir)) {
54
+ const accountsPath = join(cliproxyDir, "accounts.json");
55
+ if (existsSync(accountsPath)) {
56
+ result.cliproxyAccounts = accountsPath;
57
+ }
58
+ const configPath = join(cliproxyDir, "config.yaml");
59
+ if (existsSync(configPath)) {
60
+ result.cliproxyConfig = configPath;
61
+ }
62
+ // cliproxy/auth/*.json
63
+ const authDir = join(cliproxyDir, "auth");
64
+ if (existsSync(authDir)) {
65
+ const authFiles = safeReaddirSync(authDir);
66
+ for (const file of authFiles) {
67
+ if (file.endsWith(".json")) {
68
+ result.cliproxyAuthFiles.push(join(authDir, file));
69
+ }
70
+ }
71
+ }
72
+ }
73
+ return result;
74
+ };
75
+
76
+ // Get relative path from CCS home for zip storage (always use forward slashes)
77
+ export const getRelativePath = (absolutePath: string): string => {
78
+ const ccsHome = getCcsHome();
79
+ const relativePath = relative(ccsHome, absolutePath);
80
+ // Convert to forward slashes for zip compatibility (works on all platforms)
81
+ return relativePath.replace(/\\/g, "/");
82
+ };
83
+
84
+ // Ensure directory exists for a file path (cross-platform)
85
+ export const ensureDirectoryExists = (filePath: string): void => {
86
+ const dir = dirname(filePath);
87
+ if (dir && dir !== "." && !existsSync(dir)) {
88
+ mkdirSync(dir, { recursive: true });
89
+ }
90
+ };
91
+
92
+ // Get all backup file paths as a flat array
93
+ export const getAllBackupFilePaths = (): string[] => {
94
+ const files = getCcsBackupFiles();
95
+ const paths: string[] = [];
96
+ if (files.config) {
97
+ paths.push(files.config);
98
+ }
99
+ paths.push(...files.settingsProfiles);
100
+ if (files.cliproxyAccounts) {
101
+ paths.push(files.cliproxyAccounts);
102
+ }
103
+ if (files.cliproxyConfig) {
104
+ paths.push(files.cliproxyConfig);
105
+ }
106
+ paths.push(...files.cliproxyAuthFiles);
107
+ return paths;
108
+ };
109
+
110
+ // Get files that would be deleted in replace mode
111
+ export const getFilesToDelete = (): {
112
+ profiles: string[];
113
+ authFiles: string[];
114
+ } => {
115
+ const files = getCcsBackupFiles();
116
+ return {
117
+ profiles: files.settingsProfiles,
118
+ authFiles: files.cliproxyAuthFiles,
119
+ };
120
+ };
@@ -1,10 +1,10 @@
1
1
  import pc from "picocolors";
2
2
 
3
3
  export const colors = {
4
- green: pc.green,
5
- red: pc.red,
6
- cyan: pc.cyan,
7
- yellow: pc.yellow,
8
- bold: pc.bold,
9
- dim: pc.dim,
4
+ green: pc.green,
5
+ red: pc.red,
6
+ cyan: pc.cyan,
7
+ yellow: pc.yellow,
8
+ bold: pc.bold,
9
+ dim: pc.dim,
10
10
  };
@@ -1,22 +1,25 @@
1
- export const deepMerge = (base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> => {
2
- const result: Record<string, unknown> = { ...base };
3
- for (const [key, value] of Object.entries(override)) {
4
- const baseValue = result[key];
5
- if (isPlainObject(baseValue) && isPlainObject(value)) {
6
- result[key] = deepMerge(baseValue, value);
7
- } else {
8
- result[key] = value;
9
- }
10
- }
11
- return result;
1
+ export const deepMerge = (
2
+ base: Record<string, unknown>,
3
+ override: Record<string, unknown>,
4
+ ): Record<string, unknown> => {
5
+ const result: Record<string, unknown> = { ...base };
6
+ for (const [key, value] of Object.entries(override)) {
7
+ const baseValue = result[key];
8
+ if (isPlainObject(baseValue) && isPlainObject(value)) {
9
+ result[key] = deepMerge(baseValue, value);
10
+ } else {
11
+ result[key] = value;
12
+ }
13
+ }
14
+ return result;
12
15
  };
13
16
 
14
17
  const isPlainObject = (value: unknown): value is Record<string, unknown> => {
15
- if (value === null || typeof value !== "object") {
16
- return false;
17
- }
18
- if (Array.isArray(value)) {
19
- return false;
20
- }
21
- return Object.getPrototypeOf(value) === Object.prototype;
18
+ if (value === null || typeof value !== "object") {
19
+ return false;
20
+ }
21
+ if (Array.isArray(value)) {
22
+ return false;
23
+ }
24
+ return Object.getPrototypeOf(value) === Object.prototype;
22
25
  };
@@ -1,69 +1,81 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
 
3
3
  const hasFzf = (): boolean => {
4
- const which = spawnSync("which", ["fzf"], { stdio: "ignore" });
5
- return which.status === 0 && Boolean(process.env.TERM);
4
+ const which = spawnSync("which", ["fzf"], { stdio: "ignore" });
5
+ return which.status === 0 && Boolean(process.env.TERM);
6
6
  };
7
7
 
8
- export const selectContext = async (contexts: string[], current?: string): Promise<string | undefined> => {
9
- if (contexts.length === 0) {
10
- return undefined;
11
- }
12
- if (hasFzf()) {
13
- const input = contexts.join("\n");
14
- const result = spawnSync("fzf", [], { input, encoding: "utf8", stdio: ["pipe", "pipe", "inherit"] });
15
- if (result.status !== 0) {
16
- return undefined;
17
- }
18
- const selected = String(result.stdout).trim();
19
- return selected.length > 0 ? selected : undefined;
20
- }
21
- return promptSelect(contexts, current);
8
+ export const selectContext = async (
9
+ contexts: string[],
10
+ current?: string,
11
+ ): Promise<string | undefined> => {
12
+ if (contexts.length === 0) {
13
+ return undefined;
14
+ }
15
+ if (hasFzf()) {
16
+ const input = contexts.join("\n");
17
+ const result = spawnSync("fzf", [], {
18
+ input,
19
+ encoding: "utf8",
20
+ stdio: ["pipe", "pipe", "inherit"],
21
+ });
22
+ if (result.status !== 0) {
23
+ return undefined;
24
+ }
25
+ const selected = String(result.stdout).trim();
26
+ return selected.length > 0 ? selected : undefined;
27
+ }
28
+ return promptSelect(contexts, current);
22
29
  };
23
30
 
24
- const promptSelect = async (contexts: string[], current?: string): Promise<string | undefined> => {
25
- const lines = contexts.map((ctx, index) => {
26
- const marker = current && ctx === current ? " (current)" : "";
27
- return `${index + 1}. ${ctx}${marker}`;
28
- });
29
- process.stdout.write(`${lines.join("\n")}\nSelect a context (number): `);
30
- const input = await readStdinLine();
31
- const index = Number.parseInt(input, 10);
32
- if (!Number.isFinite(index) || index < 1 || index > contexts.length) {
33
- return undefined;
34
- }
35
- return contexts[index - 1];
31
+ const promptSelect = async (
32
+ contexts: string[],
33
+ current?: string,
34
+ ): Promise<string | undefined> => {
35
+ const lines = contexts.map((ctx, index) => {
36
+ const marker = current && ctx === current ? " (current)" : "";
37
+ return `${index + 1}. ${ctx}${marker}`;
38
+ });
39
+ process.stdout.write(`${lines.join("\n")}\nSelect a context (number): `);
40
+ const input = await readStdinLine();
41
+ const index = Number.parseInt(input, 10);
42
+ if (!Number.isFinite(index) || index < 1 || index > contexts.length) {
43
+ return undefined;
44
+ }
45
+ return contexts[index - 1];
36
46
  };
37
47
 
38
- export const promptInput = async (label: string): Promise<string | undefined> => {
39
- process.stdout.write(`${label}: `);
40
- const input = await readStdinLine();
41
- const trimmed = input.trim();
42
- return trimmed.length > 0 ? trimmed : undefined;
48
+ export const promptInput = async (
49
+ label: string,
50
+ ): Promise<string | undefined> => {
51
+ process.stdout.write(`${label}: `);
52
+ const input = await readStdinLine();
53
+ const trimmed = input.trim();
54
+ return trimmed.length > 0 ? trimmed : undefined;
43
55
  };
44
56
 
45
57
  const readStdinLine = async (): Promise<string> => {
46
- return new Promise((resolve) => {
47
- const chunks: Buffer[] = [];
48
- const onData = (chunk: Buffer) => {
49
- chunks.push(chunk);
50
- if (chunk.includes(10)) {
51
- cleanup();
52
- resolve(Buffer.concat(chunks).toString("utf8").trim());
53
- }
54
- };
55
- const onEnd = () => {
56
- cleanup();
57
- resolve(Buffer.concat(chunks).toString("utf8").trim());
58
- };
59
- const cleanup = () => {
60
- process.stdin.off("data", onData);
61
- process.stdin.off("end", onEnd);
62
- };
63
- if (process.stdin.isTTY) {
64
- process.stdin.resume();
65
- }
66
- process.stdin.on("data", onData);
67
- process.stdin.on("end", onEnd);
68
- });
58
+ return new Promise((resolve) => {
59
+ const chunks: Buffer[] = [];
60
+ const onData = (chunk: Buffer) => {
61
+ chunks.push(chunk);
62
+ if (chunk.includes(10)) {
63
+ cleanup();
64
+ resolve(Buffer.concat(chunks).toString("utf8").trim());
65
+ }
66
+ };
67
+ const onEnd = () => {
68
+ cleanup();
69
+ resolve(Buffer.concat(chunks).toString("utf8").trim());
70
+ };
71
+ const cleanup = () => {
72
+ process.stdin.off("data", onData);
73
+ process.stdin.off("end", onEnd);
74
+ };
75
+ if (process.stdin.isTTY) {
76
+ process.stdin.resume();
77
+ }
78
+ process.stdin.on("data", onData);
79
+ process.stdin.on("end", onEnd);
80
+ });
69
81
  };