Skip to content

Commit 06dc090

Browse files
committed
feat: add clsync push CLI command and push_settings tool to publish local settings to GitHub.
1 parent d3553be commit 06dc090

4 files changed

Lines changed: 314 additions & 6 deletions

File tree

bin/cli.js

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
applyAll,
1717
unstageItem,
1818
pullFromGitHub,
19+
pushToGitHub,
1920
browseRepo,
2021
getStatus,
2122
exportForPush,
@@ -43,20 +44,20 @@ ${chalk.cyan(' ██║ ██║ ╚════██║ ╚██
4344
${chalk.cyan(' ╚██████╗███████╗███████║ ██║ ██║ ╚████║╚██████╗')}
4445
${chalk.cyan(' ╚═════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝')}
4546
${chalk.dim(' ───────────────────────────────────────────────────')}
46-
${chalk.dim(' Claude Code Environment Sync')} ${chalk.cyan('v0.2.2')}
47+
${chalk.dim(' Claude Code Environment Sync')} ${chalk.cyan('v0.2.3')}
4748
`;
4849

4950
// Compact banner (for 40-54 columns)
5051
const bannerCompact = `
5152
${chalk.cyan.bold(' ╔═══════════════════════════╗')}
52-
${chalk.cyan.bold(' ║')} ${chalk.white.bold('CLSYNC')} ${chalk.dim('v0.2.2')} ${chalk.cyan.bold('║')}
53+
${chalk.cyan.bold(' ║')} ${chalk.white.bold('CLSYNC')} ${chalk.dim('v0.2.3')} ${chalk.cyan.bold('║')}
5354
${chalk.cyan.bold(' ║')} ${chalk.dim('Claude Code Sync')} ${chalk.cyan.bold('║')}
5455
${chalk.cyan.bold(' ╚═══════════════════════════╝')}
5556
`;
5657

5758
// Minimal banner (for <40 columns)
5859
const bannerMinimal = `
59-
${chalk.cyan.bold('CLSYNC')} ${chalk.dim('v0.2.2')}
60+
${chalk.cyan.bold('CLSYNC')} ${chalk.dim('v0.2.3')}
6061
${chalk.dim('Claude Code Sync')}
6162
`;
6263

@@ -563,7 +564,7 @@ if (args.length === 0) {
563564
program
564565
.name("clsync")
565566
.description("Sync Claude Code settings via ~/.clsync staging area")
566-
.version("0.2.2");
567+
.version("0.2.3");
567568

568569
// ============================================================================
569570
// INIT
@@ -823,6 +824,53 @@ program
823824
}
824825
});
825826

827+
// ============================================================================
828+
// PUSH
829+
// ============================================================================
830+
program
831+
.command("push [repo]")
832+
.description("Push settings to GitHub repository")
833+
.option("-s, --scope <scope>", "Scope: local, user, or project", "local")
834+
.option("-m, --message <msg>", "Commit message", "Update clsync settings")
835+
.option("-f, --force", "Force push (overwrites remote)")
836+
.option("-v, --verbose", "Verbose output")
837+
.action(async (repo, options) => {
838+
try {
839+
const scope = options.scope;
840+
const scopeLabel = scope === 'local' ? '~/.clsync/local' :
841+
scope === 'user' ? '~/.claude' : '.claude';
842+
843+
console.log(chalk.cyan(` 📤 Pushing from: ${scopeLabel}\n`));
844+
845+
const spinner = ora('Preparing settings for push...').start();
846+
const results = await pushToGitHub(scope, {
847+
repo,
848+
message: options.message,
849+
force: options.force,
850+
onProgress: msg => { if (options.verbose) spinner.text = msg; }
851+
});
852+
853+
if (results.pushed) {
854+
spinner.succeed(`Pushed ${results.pushed} items to ${results.repo}`);
855+
856+
console.log(chalk.dim(`\n Items pushed:`));
857+
for (const item of results.items) {
858+
console.log(chalk.dim(` ✓ ${item.type}: ${item.name}`));
859+
}
860+
861+
console.log(chalk.dim(`\n Others can now use:`));
862+
console.log(chalk.dim(` clsync pull ${results.repo}`));
863+
showSuccess('Push Complete!');
864+
} else if (results.prepared) {
865+
spinner.succeed(`Prepared ${results.prepared} items for push`);
866+
console.log(chalk.yellow(`\n ${results.instructions}`));
867+
}
868+
} catch (error) {
869+
showError(error.message);
870+
process.exit(1);
871+
}
872+
});
873+
826874
// ============================================================================
827875
// BROWSE
828876
// ============================================================================

bin/mcp-server.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { trackDocs } from "../src/index.js";
1717
// Create MCP server
1818
const server = new McpServer({
1919
name: "clsync",
20-
version: "0.2.2",
20+
version: "0.2.3",
2121
});
2222

2323
// =============================================================================
@@ -603,6 +603,7 @@ server.registerTool(
603603

604604
import {
605605
pullFromGitHub,
606+
pushToGitHub,
606607
listLocalStaged,
607608
browseRepo,
608609
applyItem,
@@ -635,6 +636,41 @@ server.registerTool(
635636
}
636637
);
637638

639+
server.registerTool(
640+
"push_settings",
641+
{
642+
description: "Push settings to a GitHub repository. Requires git to be installed and authenticated.",
643+
inputSchema: {
644+
repo: z.string().describe('GitHub repository (e.g., "owner/repo")'),
645+
scope: z.enum(["local", "user", "project"]).optional().describe('Source scope: "local" (~/.clsync/local), "user" (~/.claude), or "project" (.claude). Default: local'),
646+
message: z.string().optional().describe("Commit message"),
647+
force: z.boolean().optional().describe("Force push (overwrites remote)"),
648+
},
649+
},
650+
async ({ repo, scope = "local", message = "Update clsync settings", force = false }) => {
651+
try {
652+
const results = await pushToGitHub(scope, { repo, message, force });
653+
654+
if (results.pushed) {
655+
let text = `✅ Push Complete!\n\n`;
656+
text += `Pushed ${results.pushed} items to ${results.repo}\n\n`;
657+
text += `Items:\n`;
658+
for (const item of results.items) {
659+
text += ` - ${item.type}: ${item.name}\n`;
660+
}
661+
text += `\nOthers can now use:\n clsync pull ${results.repo}`;
662+
return { content: [{ type: "text", text }] };
663+
} else if (results.prepared) {
664+
return { content: [{ type: "text", text: results.instructions }] };
665+
}
666+
667+
return { content: [{ type: "text", text: "Push completed" }] };
668+
} catch (error) {
669+
return { content: [{ type: "text", text: `❌ ${error.message}` }], isError: true };
670+
}
671+
}
672+
);
673+
638674
server.registerTool(
639675
"browse_repo",
640676
{

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "clsync",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "Sync Claude Code environment across multiple machines - docs, skills, subagents, and more",
55
"main": "src/index.js",
66
"bin": {

src/repo-sync.js

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,3 +879,227 @@ export async function pullOnlineRepo(repoInfo, options = {}) {
879879

880880
return await pullFromGitHub(repoPath, options);
881881
}
882+
883+
// =============================================================================
884+
// PUSH TO GITHUB
885+
// =============================================================================
886+
887+
import { exec as execCallback } from "child_process";
888+
import { promisify } from "util";
889+
const exec = promisify(execCallback);
890+
891+
/**
892+
* Check if git is available
893+
*/
894+
async function isGitAvailable() {
895+
try {
896+
await exec("git --version");
897+
return true;
898+
} catch {
899+
return false;
900+
}
901+
}
902+
903+
/**
904+
* Check if directory is a git repo
905+
*/
906+
async function isGitRepo(dir) {
907+
try {
908+
await exec("git rev-parse --git-dir", { cwd: dir });
909+
return true;
910+
} catch {
911+
return false;
912+
}
913+
}
914+
915+
/**
916+
* Get git remote URL
917+
*/
918+
async function getGitRemote(dir) {
919+
try {
920+
const { stdout } = await exec("git remote get-url origin", { cwd: dir });
921+
return stdout.trim();
922+
} catch {
923+
return null;
924+
}
925+
}
926+
927+
/**
928+
* Push settings to GitHub repository
929+
* @param {string} scope - 'user', 'project', or 'local'
930+
* @param {object} options - { repo, message, force }
931+
*/
932+
export async function pushToGitHub(scope = "local", options = {}) {
933+
const { repo, message = "Update clsync settings", force = false, onProgress } = options;
934+
const log = (msg) => onProgress && onProgress(msg);
935+
936+
// Check git availability
937+
if (!(await isGitAvailable())) {
938+
throw new Error("Git is not installed. Please install git first.");
939+
}
940+
941+
// Determine source directory
942+
let sourceDir;
943+
let items;
944+
945+
if (scope === "local") {
946+
await initClsync();
947+
sourceDir = LOCAL_DIR;
948+
items = await listLocalStaged();
949+
} else if (scope === "user") {
950+
sourceDir = getUserClaudeDir();
951+
items = await scanItems(sourceDir);
952+
} else if (scope === "project") {
953+
sourceDir = getProjectClaudeDir();
954+
items = await scanItems(sourceDir);
955+
} else {
956+
throw new Error(`Invalid scope: ${scope}. Use 'local', 'user', or 'project'`);
957+
}
958+
959+
if (items.length === 0) {
960+
throw new Error(`No settings found in ${scope} scope to push.`);
961+
}
962+
963+
// Create temp directory for push
964+
const tempDir = join(os.tmpdir(), `clsync-push-${Date.now()}`);
965+
await mkdir(tempDir, { recursive: true });
966+
967+
log(`Preparing ${items.length} items for push...`);
968+
969+
// Copy items to temp directory
970+
for (const dir of SETTINGS_DIRS) {
971+
await mkdir(join(tempDir, dir), { recursive: true });
972+
}
973+
974+
for (const item of items) {
975+
const sourcePath = join(sourceDir, item.path);
976+
const destPath = join(tempDir, item.path);
977+
978+
await mkdir(dirname(destPath), { recursive: true });
979+
980+
if (item.type === "skill") {
981+
await cp(sourcePath, destPath, { recursive: true });
982+
} else {
983+
const content = await readFile(sourcePath, "utf-8");
984+
await writeFile(destPath, content, "utf-8");
985+
}
986+
}
987+
988+
// Create clsync.json metadata
989+
const clsyncJson = {
990+
$schema: "https://clsync.dev/schema/v1.json",
991+
version: "1.0.0",
992+
description: "Claude Code settings repository",
993+
author: os.userInfo().username,
994+
updated_at: new Date().toISOString(),
995+
items: items.map((item) => ({
996+
type: item.type,
997+
name: item.name,
998+
path: item.path,
999+
description: item.description || null,
1000+
})),
1001+
stats: {
1002+
skills: items.filter((i) => i.type === "skill").length,
1003+
agents: items.filter((i) => i.type === "agent").length,
1004+
output_styles: items.filter((i) => i.type === "output-style").length,
1005+
total: items.length,
1006+
},
1007+
};
1008+
1009+
await writeFile(
1010+
join(tempDir, "clsync.json"),
1011+
JSON.stringify(clsyncJson, null, 2),
1012+
"utf-8"
1013+
);
1014+
1015+
// Create README.md
1016+
const readmeContent = `# Claude Code Settings
1017+
1018+
This repository contains Claude Code settings managed by [clsync](https://github.com/workromancer/clsync).
1019+
1020+
## Contents
1021+
1022+
${items.map(i => `- **${i.type}**: ${i.name}`).join('\n')}
1023+
1024+
## Usage
1025+
1026+
\`\`\`bash
1027+
# Install clsync
1028+
npm install -g clsync
1029+
1030+
# Pull and apply these settings
1031+
clsync pull ${repo || 'owner/repo'}
1032+
clsync apply <setting-name>
1033+
\`\`\`
1034+
1035+
## Stats
1036+
1037+
- Skills: ${clsyncJson.stats.skills}
1038+
- Agents: ${clsyncJson.stats.agents}
1039+
- Output Styles: ${clsyncJson.stats.output_styles}
1040+
1041+
---
1042+
*Last updated: ${new Date().toLocaleString()}*
1043+
`;
1044+
1045+
await writeFile(join(tempDir, "README.md"), readmeContent, "utf-8");
1046+
1047+
// Initialize git and push
1048+
log("Initializing git repository...");
1049+
await exec("git init", { cwd: tempDir });
1050+
await exec("git add -A", { cwd: tempDir });
1051+
await exec(`git commit -m "${message}"`, { cwd: tempDir });
1052+
1053+
if (repo) {
1054+
const repoUrl = repo.startsWith("http")
1055+
? repo
1056+
: `https://github.com/${repo}.git`;
1057+
1058+
log(`Pushing to ${repo}...`);
1059+
1060+
try {
1061+
await exec(`git remote add origin ${repoUrl}`, { cwd: tempDir });
1062+
} catch {
1063+
// Remote might already exist
1064+
}
1065+
1066+
const forceFlag = force ? " --force" : "";
1067+
try {
1068+
await exec(`git push -u origin main${forceFlag}`, { cwd: tempDir });
1069+
} catch (error) {
1070+
// Try master branch if main fails
1071+
try {
1072+
await exec(`git branch -m master main`, { cwd: tempDir });
1073+
await exec(`git push -u origin main${forceFlag}`, { cwd: tempDir });
1074+
} catch {
1075+
throw new Error(
1076+
`Failed to push to ${repo}.\n\n` +
1077+
`Make sure:\n` +
1078+
` 1. The repository exists on GitHub\n` +
1079+
` 2. You have push access to it\n` +
1080+
` 3. You're authenticated with git (gh auth login or git credentials)\n\n` +
1081+
`Error: ${error.message}`
1082+
);
1083+
}
1084+
}
1085+
1086+
// Cleanup
1087+
await rm(tempDir, { recursive: true, force: true });
1088+
1089+
return {
1090+
pushed: items.length,
1091+
items,
1092+
repo,
1093+
scope,
1094+
};
1095+
} else {
1096+
// No repo specified - return temp directory path for manual push
1097+
return {
1098+
prepared: items.length,
1099+
items,
1100+
tempDir,
1101+
scope,
1102+
instructions: `Files prepared at: ${tempDir}\n\nTo push manually:\n cd ${tempDir}\n git remote add origin https://github.com/YOUR/REPO.git\n git push -u origin main`,
1103+
};
1104+
}
1105+
}

0 commit comments

Comments
 (0)