@leynier/ccst 0.1.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/.claude/settings.local.json +9 -0
- package/.github/workflows/publish.yml +32 -0
- package/AGENTS.md +105 -0
- package/CLAUDE.md +1 -0
- package/GEMINI.md +1 -0
- package/LICENSE +21 -0
- package/biome.json +34 -0
- package/index.ts +1 -0
- package/package.json +28 -0
- package/readme.md +321 -0
- package/src/commands/completions.ts +49 -0
- package/src/commands/create.ts +5 -0
- package/src/commands/delete.ts +9 -0
- package/src/commands/edit.ts +13 -0
- package/src/commands/export.ts +13 -0
- package/src/commands/import-profiles/ccs.ts +82 -0
- package/src/commands/import-profiles/configs.ts +68 -0
- package/src/commands/import.ts +8 -0
- package/src/commands/list.ts +5 -0
- package/src/commands/merge.ts +19 -0
- package/src/commands/rename.ts +14 -0
- package/src/commands/show.ts +13 -0
- package/src/commands/switch.ts +9 -0
- package/src/commands/unset.ts +5 -0
- package/src/core/context-manager.test.ts +74 -0
- package/src/core/context-manager.ts +397 -0
- package/src/core/merge-manager.test.ts +38 -0
- package/src/core/merge-manager.ts +148 -0
- package/src/core/settings-level.ts +11 -0
- package/src/core/state.ts +29 -0
- package/src/index.ts +148 -0
- package/src/types/index.ts +14 -0
- package/src/utils/colors.ts +10 -0
- package/src/utils/deep-merge.ts +22 -0
- package/src/utils/interactive.ts +69 -0
- package/src/utils/json.ts +19 -0
- package/src/utils/paths.ts +58 -0
- package/tsconfig.json +29 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { getPaths } from "./utils/paths.js";
|
|
3
|
+
import { ContextManager } from "./core/context-manager.js";
|
|
4
|
+
import { resolveSettingsLevel } from "./core/settings-level.js";
|
|
5
|
+
import { listCommand } from "./commands/list.js";
|
|
6
|
+
import { switchCommand, switchPreviousCommand } from "./commands/switch.js";
|
|
7
|
+
import { createCommand } from "./commands/create.js";
|
|
8
|
+
import { deleteCommand } from "./commands/delete.js";
|
|
9
|
+
import { renameCommand } from "./commands/rename.js";
|
|
10
|
+
import { editCommand } from "./commands/edit.js";
|
|
11
|
+
import { showCommand } from "./commands/show.js";
|
|
12
|
+
import { exportCommand } from "./commands/export.js";
|
|
13
|
+
import { importCommand } from "./commands/import.js";
|
|
14
|
+
import { unsetCommand } from "./commands/unset.js";
|
|
15
|
+
import { mergeCommand, mergeHistoryCommand, unmergeCommand } from "./commands/merge.js";
|
|
16
|
+
import { completionsCommand } from "./commands/completions.js";
|
|
17
|
+
import { importFromCcs } from "./commands/import-profiles/ccs.js";
|
|
18
|
+
import { importFromConfigs } from "./commands/import-profiles/configs.js";
|
|
19
|
+
|
|
20
|
+
export const program = new Command();
|
|
21
|
+
|
|
22
|
+
const main = async (): Promise<void> => {
|
|
23
|
+
program
|
|
24
|
+
.name("ccst")
|
|
25
|
+
.description("Claude Code Switch Tools")
|
|
26
|
+
.argument("[context]", "context name")
|
|
27
|
+
.option("-d, --delete", "delete context")
|
|
28
|
+
.option("-c, --current", "print current context")
|
|
29
|
+
.option("-r, --rename", "rename context")
|
|
30
|
+
.option("-n, --new", "create new context")
|
|
31
|
+
.option("-e, --edit", "edit context")
|
|
32
|
+
.option("-s, --show", "show context")
|
|
33
|
+
.option("--export", "export context to stdout")
|
|
34
|
+
.option("--import", "import context from stdin")
|
|
35
|
+
.option("-u, --unset", "unset current context")
|
|
36
|
+
.option("--completions <shell>", "generate completions")
|
|
37
|
+
.option("-q, --quiet", "show only current context")
|
|
38
|
+
.option("--in-project", "use project settings level")
|
|
39
|
+
.option("--local", "use local settings level")
|
|
40
|
+
.option("--merge-from <source>", "merge permissions from source")
|
|
41
|
+
.option("--unmerge <source>", "remove permissions merged from source")
|
|
42
|
+
.option("--merge-history", "show merge history")
|
|
43
|
+
.option("--merge-full", "merge full settings")
|
|
44
|
+
.allowExcessArguments(false);
|
|
45
|
+
const importCommandGroup = program.command("import").description("import profiles");
|
|
46
|
+
importCommandGroup
|
|
47
|
+
.command("ccs")
|
|
48
|
+
.description("import from CCS settings")
|
|
49
|
+
.option("-d, --configs-dir <dir>", "configs directory")
|
|
50
|
+
.action(async (options) => {
|
|
51
|
+
const manager = new ContextManager(getPaths("user"));
|
|
52
|
+
await importFromCcs(manager, options.configsDir);
|
|
53
|
+
});
|
|
54
|
+
importCommandGroup
|
|
55
|
+
.command("configs")
|
|
56
|
+
.description("import from configs directory")
|
|
57
|
+
.option("-d, --configs-dir <dir>", "configs directory")
|
|
58
|
+
.action(async (options) => {
|
|
59
|
+
const manager = new ContextManager(getPaths("user"));
|
|
60
|
+
await importFromConfigs(manager, options.configsDir);
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
await program.parseAsync(process.argv);
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const options = program.opts();
|
|
68
|
+
const [context] = program.args as [string | undefined];
|
|
69
|
+
if (context === "import") {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (options.completions) {
|
|
73
|
+
completionsCommand(options.completions);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const level = resolveSettingsLevel(options);
|
|
77
|
+
const manager = new ContextManager(getPaths(level));
|
|
78
|
+
if (options.current) {
|
|
79
|
+
const current = await manager.getCurrentContext();
|
|
80
|
+
if (current) {
|
|
81
|
+
console.log(current);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (options.unset) {
|
|
86
|
+
await unsetCommand(manager);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (options.delete) {
|
|
90
|
+
await deleteCommand(manager, context);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (options.rename) {
|
|
94
|
+
await renameCommand(manager, context);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (options.new) {
|
|
98
|
+
if (!context) {
|
|
99
|
+
await manager.interactiveCreateContext();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await createCommand(manager, context);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (options.edit) {
|
|
106
|
+
await editCommand(manager, context);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (options.show) {
|
|
110
|
+
await showCommand(manager, context);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (options.export) {
|
|
114
|
+
await exportCommand(manager, context);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (options.import) {
|
|
118
|
+
await importCommand(manager, context);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (options.mergeFrom) {
|
|
122
|
+
await mergeCommand(manager, options.mergeFrom, context, options.mergeFull);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (options.unmerge) {
|
|
126
|
+
await unmergeCommand(manager, options.unmerge, context, options.mergeFull);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (options.mergeHistory) {
|
|
130
|
+
await mergeHistoryCommand(manager, context);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (context === "-") {
|
|
134
|
+
await switchPreviousCommand(manager);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (context) {
|
|
138
|
+
await switchCommand(manager, context);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (process.env.CCTX_INTERACTIVE === "1") {
|
|
142
|
+
await manager.interactiveSelect();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await listCommand(manager, options.quiet);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
await main();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type SettingsLevel = "user" | "project" | "local";
|
|
2
|
+
|
|
3
|
+
export type State = {
|
|
4
|
+
current?: string;
|
|
5
|
+
previous?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type MergeHistoryEntry = {
|
|
9
|
+
source: string;
|
|
10
|
+
mergedItems: string[];
|
|
11
|
+
timestamp: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type MergeHistory = MergeHistoryEntry[];
|
|
@@ -0,0 +1,22 @@
|
|
|
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;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
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;
|
|
22
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const hasFzf = (): boolean => {
|
|
4
|
+
const which = spawnSync("which", ["fzf"], { stdio: "ignore" });
|
|
5
|
+
return which.status === 0 && Boolean(process.env.TERM);
|
|
6
|
+
};
|
|
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);
|
|
22
|
+
};
|
|
23
|
+
|
|
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];
|
|
36
|
+
};
|
|
37
|
+
|
|
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;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
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
|
+
});
|
|
69
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export const readJson = async <T>(filePath: string): Promise<T> => {
|
|
4
|
+
const text = await Bun.file(filePath).text();
|
|
5
|
+
return JSON.parse(text) as T;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const readJsonIfExists = async <T>(filePath: string, fallback: T): Promise<T> => {
|
|
9
|
+
if (!existsSync(filePath)) {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
const text = await Bun.file(filePath).text();
|
|
13
|
+
return JSON.parse(text) as T;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const writeJson = async (filePath: string, value: unknown): Promise<void> => {
|
|
17
|
+
const payload = `${JSON.stringify(value, null, 2)}\n`;
|
|
18
|
+
await Bun.write(filePath, payload);
|
|
19
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
4
|
+
import type { SettingsLevel } from "../types/index.js";
|
|
5
|
+
|
|
6
|
+
export type Paths = {
|
|
7
|
+
contextsDir: string;
|
|
8
|
+
settingsPath: string;
|
|
9
|
+
statePath: string;
|
|
10
|
+
settingsLevel: SettingsLevel;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const getPaths = (level: SettingsLevel): Paths => {
|
|
14
|
+
const homeDir = homedir();
|
|
15
|
+
const currentDir = process.cwd();
|
|
16
|
+
if (level === "user") {
|
|
17
|
+
const claudeDir = path.join(homeDir, ".claude");
|
|
18
|
+
const contextsDir = path.join(claudeDir, "settings");
|
|
19
|
+
return {
|
|
20
|
+
contextsDir,
|
|
21
|
+
settingsPath: path.join(claudeDir, "settings.json"),
|
|
22
|
+
statePath: path.join(contextsDir, ".cctx-state.json"),
|
|
23
|
+
settingsLevel: level,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (level === "project") {
|
|
27
|
+
const claudeDir = path.join(currentDir, ".claude");
|
|
28
|
+
const contextsDir = path.join(claudeDir, "settings");
|
|
29
|
+
return {
|
|
30
|
+
contextsDir,
|
|
31
|
+
settingsPath: path.join(claudeDir, "settings.json"),
|
|
32
|
+
statePath: path.join(contextsDir, ".cctx-state.json"),
|
|
33
|
+
settingsLevel: level,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const claudeDir = path.join(currentDir, ".claude");
|
|
37
|
+
const contextsDir = path.join(claudeDir, "settings");
|
|
38
|
+
return {
|
|
39
|
+
contextsDir,
|
|
40
|
+
settingsPath: path.join(claudeDir, "settings.local.json"),
|
|
41
|
+
statePath: path.join(contextsDir, ".cctx-state.local.json"),
|
|
42
|
+
settingsLevel: level,
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const hasProjectContexts = (): boolean => {
|
|
47
|
+
const contextsDir = path.join(process.cwd(), ".claude", "settings");
|
|
48
|
+
try {
|
|
49
|
+
const entries = readdirSync(contextsDir);
|
|
50
|
+
return entries.some((name) => name.endsWith(".json") && !name.startsWith("."));
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const hasLocalContexts = (): boolean => {
|
|
57
|
+
return existsSync(path.join(process.cwd(), ".claude", "settings.local.json"));
|
|
58
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|