aicsync 0.1.0__py3-none-any.whl

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.
aics/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
aics/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
aics/bundle.py ADDED
@@ -0,0 +1,180 @@
1
+ import hashlib
2
+ import json
3
+ import shutil
4
+ import tarfile
5
+ from pathlib import Path
6
+
7
+ from .config import CLAUDE_DIR, CURSOR_DIR
8
+ from .render import now_iso, host, render_markdown
9
+ from .sanitize import sanitize
10
+ from .scan import scan_claude, scan_cursor
11
+
12
+ _c = {
13
+ "reset": "0",
14
+ "bold": "1",
15
+ "dim": "2",
16
+ "red": "31",
17
+ "green": "32",
18
+ "yellow": "33",
19
+ "blue": "34",
20
+ "magenta": "35",
21
+ "cyan": "36",
22
+ "gray": "90",
23
+ }
24
+
25
+
26
+ def sha256_file(p: Path):
27
+ h = hashlib.sha256()
28
+ with open(p, "rb") as f:
29
+ for chunk in iter(lambda: f.read(65536), b""):
30
+ h.update(chunk)
31
+ return h.hexdigest()
32
+
33
+
34
+ def copy_dir(src: Path, dst: Path):
35
+ if not src.exists():
36
+ return []
37
+ copied = []
38
+ dst.mkdir(parents=True, exist_ok=True)
39
+ for item in src.iterdir():
40
+ if item.name.startswith("."):
41
+ continue
42
+ target = dst / item.name
43
+ if item.is_dir():
44
+ shutil.copytree(item, target, dirs_exist_ok=True)
45
+ for f in target.rglob("*"):
46
+ if f.is_file():
47
+ copied.append(str(f.relative_to(dst.parent.parent)))
48
+ else:
49
+ shutil.copy2(item, target)
50
+ copied.append(str(target.relative_to(dst.parent.parent)))
51
+ return copied
52
+
53
+
54
+ def export_bundle(out_dir: Path, include_secrets=False):
55
+ claude = scan_claude()
56
+ cursor = scan_cursor()
57
+ csettings, cred = sanitize(claude["settings"], include_secrets)
58
+ cursor_settings, rred = sanitize(cursor["settings"], include_secrets)
59
+ claude["settings"] = csettings
60
+ cursor["settings"] = cursor_settings
61
+ redacted = cred + rred
62
+
63
+ out_dir.mkdir(parents=True, exist_ok=True)
64
+ assets = out_dir / "assets"
65
+ asset_files = []
66
+ for kind in ("skills", "commands", "agents"):
67
+ asset_files += copy_dir(CLAUDE_DIR / kind, assets / "claude" / kind)
68
+ asset_files += copy_dir(CURSOR_DIR / "rules", assets / "cursor" / "rules")
69
+
70
+ (out_dir / "claude_mcp.json").write_text(
71
+ json.dumps(
72
+ {
73
+ "global": claude["mcpServers"],
74
+ "projects": claude["projects"],
75
+ "settings": csettings,
76
+ "plugins": claude["plugins"],
77
+ "enabledPlugins": claude["enabledPlugins"],
78
+ },
79
+ indent=2,
80
+ ensure_ascii=False,
81
+ )
82
+ )
83
+ (out_dir / "cursor_config.json").write_text(
84
+ json.dumps(
85
+ {"mcp": cursor["mcp"], "settings": cursor_settings, "extensions": cursor["extensions"]},
86
+ indent=2,
87
+ ensure_ascii=False,
88
+ )
89
+ )
90
+ (out_dir / "INSTALL.md").write_text(render_markdown(claude, cursor, redacted, guide=True))
91
+
92
+ manifest = {
93
+ "tool": "aics",
94
+ "version": 1,
95
+ "exportedAt": now_iso(),
96
+ "sourceHost": host(),
97
+ "clients": ["claude", "cursor"],
98
+ "redacted": redacted,
99
+ "assets": asset_files,
100
+ "counts": {
101
+ "claude": {
102
+ "mcp": len(claude["mcpServers"]),
103
+ "plugins": len(claude["plugins"]),
104
+ "skills": len(claude["skills"]),
105
+ "commands": len(claude["commands"]),
106
+ "agents": len(claude["agents"]),
107
+ },
108
+ "cursor": {
109
+ "mcp": len(cursor["mcp"]),
110
+ "rules": len(cursor["rules"]),
111
+ "extensions": len(cursor["extensions"]),
112
+ },
113
+ },
114
+ }
115
+ (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
116
+ return manifest
117
+
118
+
119
+ def make_tar(bundle_dir: Path) -> Path:
120
+ tar_path = bundle_dir.parent / f"{bundle_dir.name}.tar.gz"
121
+ with tarfile.open(tar_path, "w:gz") as tar:
122
+ tar.add(bundle_dir, arcname=bundle_dir.name)
123
+ return tar_path
124
+
125
+
126
+ def load_manifest(bundle: Path):
127
+ mf = bundle / "manifest.json"
128
+ if not mf.exists():
129
+ return None
130
+ return json.loads(mf.read_text())
131
+
132
+
133
+ def list_bundle(bundle: Path, color=False) -> str:
134
+ def p(text, *styles):
135
+ return f"\033[{';'.join(_c[s] for s in styles)}m{text}\033[0m" if color else text
136
+
137
+ m = load_manifest(bundle)
138
+ if not m:
139
+ return f"not an aics bundle (no manifest.json): {bundle}"
140
+ out = [p(f"bundle @ {bundle}", "bold", "cyan"), f"exported: {m['exportedAt']} from {m['sourceHost']}"]
141
+ for c, counts in m["counts"].items():
142
+ out.append(f"{c}: " + ", ".join(f"{k}={v}" for k, v in counts.items()))
143
+ out.append(f"assets: {len(m['assets'])} files")
144
+ out.append(f"redacted: {len(m['redacted'])} fields")
145
+ return "\n".join(out) + "\n"
146
+
147
+
148
+ def diff_bundle(bundle: Path, color=False) -> str:
149
+ def p(text, *styles):
150
+ return f"\033[{';'.join(_c[s] for s in styles)}m{text}\033[0m" if color else text
151
+
152
+ m = load_manifest(bundle)
153
+ if not m:
154
+ return "not an aics bundle"
155
+ claude = scan_claude()
156
+ cursor = scan_cursor()
157
+ out = [p("diff (bundle -> local):", "bold", "cyan")]
158
+ bc = m["counts"]["claude"]
159
+ cc = {
160
+ "mcp": len(claude["mcpServers"]),
161
+ "plugins": len(claude["plugins"]),
162
+ "skills": len(claude["skills"]),
163
+ "commands": len(claude["commands"]),
164
+ "agents": len(claude["agents"]),
165
+ }
166
+ for k in bc:
167
+ sign = "==" if bc[k] == cc[k] else ("+local" if cc[k] > bc[k] else "+bundle")
168
+ style = "green" if sign == "==" else "yellow"
169
+ out.append(f" claude.{k}: bundle={bc[k]} local={cc[k]} " + p(sign, style))
170
+ bcur = m["counts"]["cursor"]
171
+ curc = {
172
+ "mcp": len(cursor["mcp"]),
173
+ "rules": len(cursor["rules"]),
174
+ "extensions": len(cursor["extensions"]),
175
+ }
176
+ for k in bcur:
177
+ sign = "==" if bcur[k] == curc[k] else ("+local" if curc[k] > bcur[k] else "+bundle")
178
+ style = "green" if sign == "==" else "yellow"
179
+ out.append(f" cursor.{k}: bundle={bcur[k]} local={curc[k]} " + p(sign, style))
180
+ return "\n".join(out) + "\n"
aics/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from . import __version__
6
+ from .bundle import diff_bundle, export_bundle, list_bundle, load_manifest, make_tar
7
+ from .errors import AicsError
8
+ from .log import info, set_quiet, set_verbose
9
+ from .render import render_markdown
10
+ from .sanitize import sanitize
11
+ from .scan import scan_claude, scan_cursor
12
+ from .tui import confirm, header, paint, pick, prompt_default, prompt_required, supports
13
+
14
+
15
+ def _empty_cursor():
16
+ return {"mcp": {}, "rules": [], "settings": {}, "extensions": []}
17
+
18
+
19
+ def _empty_claude():
20
+ return {
21
+ "mcpServers": {},
22
+ "projects": {},
23
+ "settings": {},
24
+ "plugins": [],
25
+ "enabledPlugins": {},
26
+ "skills": [],
27
+ "commands": [],
28
+ "agents": [],
29
+ }
30
+
31
+
32
+ def cmd_status(args):
33
+ color = supports()
34
+ claude = scan_claude()
35
+ cursor = scan_cursor()
36
+ cs, cred = sanitize(claude["settings"])
37
+ curs, rred = sanitize(cursor["settings"])
38
+ claude["settings"] = cs
39
+ cursor["settings"] = curs
40
+ if args.client == "claude":
41
+ sys.stdout.write(render_markdown(claude, _empty_cursor(), guide=False, color=color))
42
+ elif args.client == "cursor":
43
+ sys.stdout.write(render_markdown(_empty_claude(), cursor, guide=False, color=color))
44
+ else:
45
+ sys.stdout.write(render_markdown(claude, cursor, cred + rred, guide=False, color=color))
46
+
47
+
48
+ def cmd_export(args):
49
+ out = Path(args.output).expanduser().resolve()
50
+ mf = export_bundle(out, include_secrets=args.include_secrets)
51
+ info(f"exported to {out}")
52
+ info(f"redacted {len(mf['redacted'])} secret fields" + (" (secrets kept)" if args.include_secrets else ""))
53
+ if args.tar:
54
+ info(f"tar: {make_tar(out)}")
55
+
56
+
57
+ def cmd_list(args):
58
+ sys.stdout.write(list_bundle(Path(args.bundle).expanduser().resolve(), color=supports()))
59
+
60
+
61
+ def cmd_diff(args):
62
+ sys.stdout.write(diff_bundle(Path(args.bundle).expanduser().resolve(), color=supports()))
63
+
64
+
65
+ def build_parser():
66
+ ap = argparse.ArgumentParser(prog="aics", description="AI client config sync (Claude Code / Cursor)")
67
+ ap.add_argument("-V", "--version", action="version", version=f"aics {__version__}")
68
+ ap.add_argument("-v", "--verbose", action="store_true", help="verbose logging to stderr")
69
+ ap.add_argument("-q", "--quiet", action="store_true", help="suppress progress logs")
70
+ sub = ap.add_subparsers(dest="cmd", required=False)
71
+
72
+ sp = sub.add_parser("status", help="show local claude/cursor config inventory")
73
+ sp.add_argument("--client", choices=["claude", "cursor"])
74
+ sp.set_defaults(func=cmd_status)
75
+
76
+ sp = sub.add_parser("export", help="export config bundle to a directory")
77
+ sp.add_argument("-o", "--output", default="./aics-bundle")
78
+ sp.add_argument("--include-secrets", action="store_true")
79
+ sp.add_argument("--tar", action="store_true")
80
+ sp.set_defaults(func=cmd_export)
81
+
82
+ sp = sub.add_parser("list", help="list bundle contents")
83
+ sp.add_argument("bundle")
84
+ sp.set_defaults(func=cmd_list)
85
+
86
+ sp = sub.add_parser("diff", help="compare bundle vs local")
87
+ sp.add_argument("bundle")
88
+ sp.set_defaults(func=cmd_diff)
89
+
90
+ sp = sub.add_parser("install", help="apply a bundle to local machine")
91
+ sp.add_argument("bundle")
92
+ sp.add_argument("--client", choices=["claude", "cursor"])
93
+ sp.add_argument("--force", action="store_true", help="overwrite existing items")
94
+ sp.add_argument("--yes", action="store_true", help="run network installs (plugins/extensions)")
95
+ sp.set_defaults(func=_cmd_install)
96
+
97
+ sp = sub.add_parser("convert", help="convert a Claude SKILL.md to a Cursor .mdc rule")
98
+ sp.add_argument("--skill", required=True)
99
+ sp.add_argument("--out", required=True)
100
+ sp.set_defaults(func=_cmd_convert)
101
+
102
+ return ap
103
+
104
+
105
+ def _cmd_install(args):
106
+ bundle = Path(args.bundle).expanduser().resolve()
107
+ if not load_manifest(bundle):
108
+ raise AicsError(f"not an aics bundle: {bundle}")
109
+ if sys.stdin.isatty() and not args.yes:
110
+ sys.stdout.write(diff_bundle(bundle, color=supports()))
111
+ if not confirm("Apply these changes?"):
112
+ info("aborted")
113
+ return
114
+ from .installer import cmd_install
115
+
116
+ cmd_install(args)
117
+
118
+
119
+ def _cmd_convert(args):
120
+ from .convert import cmd_convert
121
+
122
+ cmd_convert(args)
123
+
124
+
125
+ def menu():
126
+ from types import SimpleNamespace
127
+
128
+ print(f"\n{paint('aics', 'bold', 'cyan')} {paint('— AI client config sync', 'dim')}\n")
129
+ items = [
130
+ ("status", "show local config inventory"),
131
+ ("export", "pack a bundle"),
132
+ ("list", "show a bundle's contents"),
133
+ ("diff", "compare a bundle vs local"),
134
+ ("install", "apply a bundle"),
135
+ ("convert", "Claude skill → Cursor rule"),
136
+ ("quit", "exit"),
137
+ ]
138
+ i = pick("choose an action", [f"{n} — {d}" for n, d in items])
139
+ if i is None:
140
+ return
141
+ name = items[i][0]
142
+ if name == "quit":
143
+ return
144
+ if name == "status":
145
+ cmd_status(SimpleNamespace(client=None))
146
+ elif name == "export":
147
+ out = prompt_default("output dir", "./aics-bundle")
148
+ sec = confirm("include plaintext secrets?")
149
+ tar = confirm("also create tar.gz?")
150
+ cmd_export(SimpleNamespace(output=out, include_secrets=sec, tar=tar))
151
+ elif name == "list":
152
+ cmd_list(SimpleNamespace(bundle=prompt_required("bundle path")))
153
+ elif name == "diff":
154
+ cmd_diff(SimpleNamespace(bundle=prompt_required("bundle path")))
155
+ elif name == "install":
156
+ _cmd_install(SimpleNamespace(
157
+ bundle=prompt_required("bundle path"),
158
+ client=None,
159
+ force=False,
160
+ yes=confirm("also run network installs (plugins/extensions)?"),
161
+ ))
162
+ elif name == "convert":
163
+ cmd_convert(SimpleNamespace(
164
+ skill=prompt_required("skill SKILL.md path"),
165
+ out=prompt_required("output .mdc path"),
166
+ ))
167
+
168
+
169
+ def main(argv=None):
170
+ ap = build_parser()
171
+ args = ap.parse_args(argv)
172
+ set_verbose(args.verbose)
173
+ set_quiet(args.quiet)
174
+ if not getattr(args, "cmd", None):
175
+ if sys.stdin.isatty():
176
+ menu()
177
+ return
178
+ ap.print_help()
179
+ sys.exit(0)
180
+ try:
181
+ args.func(args)
182
+ except AicsError as e:
183
+ print(f"error: {e}", file=sys.stderr)
184
+ sys.exit(1)
185
+ except KeyboardInterrupt:
186
+ sys.exit(130)
aics/config.py ADDED
@@ -0,0 +1,19 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+
5
+
6
+ def _resolve_home() -> Path:
7
+ return Path(os.environ.get("AICS_HOME") or os.environ.get("HOME") or os.path.expanduser("~"))
8
+
9
+
10
+ HOME = _resolve_home()
11
+ CLAUDE_DIR = HOME / ".claude"
12
+ CLAUDE_JSON = HOME / ".claude.json"
13
+ CURSOR_DIR = HOME / ".cursor"
14
+ CURSOR_MCP = CURSOR_DIR / "mcp.json"
15
+ CURSOR_EXT_JSON = CURSOR_DIR / "extensions" / "extensions.json"
16
+ CURSOR_USER_SETTINGS = HOME / "Library" / "Application Support" / "Cursor" / "User" / "settings.json"
17
+ AICS_DIR = HOME / ".aics"
18
+
19
+ SECRET_RE = re.compile(r"token|key|secret|password|auth|credential", re.I)
aics/convert.py ADDED
@@ -0,0 +1,33 @@
1
+ from pathlib import Path
2
+
3
+ from .log import info
4
+
5
+
6
+ def convert_skill_to_rule(skill_path: Path, out_path: Path):
7
+ text = skill_path.read_text()
8
+ front = {}
9
+ body = text
10
+ if text.startswith("---"):
11
+ end = text.find("---", 3)
12
+ if end != -1:
13
+ block = text[3:end]
14
+ body = text[end + 3 :].lstrip("\n")
15
+ for line in block.splitlines():
16
+ if ":" in line:
17
+ k, v = line.split(":", 1)
18
+ front[k.strip()] = v.strip()
19
+ desc = front.get("description") or ""
20
+ rule = "---\n"
21
+ rule += f"description: {desc}\n"
22
+ rule += "globs: \n"
23
+ rule += "alwaysApply: false\n"
24
+ rule += "---\n\n"
25
+ rule += body.strip() + "\n"
26
+ rule += "\n<!-- TODO: Cursor rules have no skill auto-trigger; review when this should apply. -->\n"
27
+ out_path.parent.mkdir(parents=True, exist_ok=True)
28
+ out_path.write_text(rule)
29
+ info(f"wrote {out_path}")
30
+
31
+
32
+ def cmd_convert(args):
33
+ convert_skill_to_rule(Path(args.skill).expanduser(), Path(args.out).expanduser())
aics/errors.py ADDED
@@ -0,0 +1,10 @@
1
+ import sys
2
+
3
+
4
+ class AicsError(Exception):
5
+ pass
6
+
7
+
8
+ def fail(msg: str, code: int = 1):
9
+ print(f"error: {msg}", file=sys.stderr)
10
+ sys.exit(code)
aics/installer.py ADDED
@@ -0,0 +1,178 @@
1
+ import json
2
+ import shutil
3
+ import subprocess
4
+
5
+ from .config import (
6
+ AICS_DIR,
7
+ CLAUDE_DIR,
8
+ CLAUDE_JSON,
9
+ CURSOR_DIR,
10
+ CURSOR_MCP,
11
+ CURSOR_USER_SETTINGS,
12
+ )
13
+ from .log import info
14
+ from .render import now_iso
15
+ from .scan import read_json
16
+
17
+
18
+ def merge(dst, src, force=False):
19
+ for k, v in src.items():
20
+ if isinstance(v, dict) and isinstance(dst.get(k), dict):
21
+ merge(dst[k], v, force)
22
+ elif force or k not in dst or dst[k] is None:
23
+ dst[k] = v
24
+ return dst
25
+
26
+
27
+ def backup_current(ts):
28
+ bdir = AICS_DIR / "backup" / ts
29
+ bdir.mkdir(parents=True, exist_ok=True)
30
+ targets = [
31
+ CLAUDE_JSON,
32
+ CLAUDE_DIR / "settings.json",
33
+ CLAUDE_DIR / "settings.local.json",
34
+ CURSOR_MCP,
35
+ CURSOR_USER_SETTINGS,
36
+ ]
37
+ for t in targets:
38
+ if t.exists():
39
+ rel = t.relative_to(AICS_DIR.parent)
40
+ dst = bdir / rel
41
+ dst.parent.mkdir(parents=True, exist_ok=True)
42
+ shutil.copy2(t, dst)
43
+ for kind in ("skills", "commands", "agents"):
44
+ d = CLAUDE_DIR / kind
45
+ if d.exists():
46
+ shutil.copytree(d, bdir / "claude" / kind, dirs_exist_ok=True)
47
+ crd = CURSOR_DIR / "rules"
48
+ if crd.exists():
49
+ shutil.copytree(crd, bdir / "cursor" / "rules", dirs_exist_ok=True)
50
+ return bdir
51
+
52
+
53
+ def _copy_assets(src, dst, force=False):
54
+ if not src.exists():
55
+ return 0
56
+ dst.mkdir(parents=True, exist_ok=True)
57
+ n = 0
58
+ for item in src.iterdir():
59
+ if item.name.startswith("."):
60
+ continue
61
+ target = dst / item.name
62
+ if target.exists() and not force:
63
+ continue
64
+ if item.is_dir():
65
+ shutil.copytree(item, target, dirs_exist_ok=True)
66
+ else:
67
+ shutil.copy2(item, target)
68
+ n += 1
69
+ return n
70
+
71
+
72
+ def apply_claude(bundle, force=False, yes=False):
73
+ data = read_json(bundle / "claude_mcp.json")
74
+ cj = read_json(CLAUDE_JSON)
75
+ cj.setdefault("mcpServers", {})
76
+ merged = 0
77
+ total = 0
78
+ for name, srv in (data.get("global") or {}).items():
79
+ total += 1
80
+ if name not in cj["mcpServers"] or force:
81
+ cj["mcpServers"][name] = srv
82
+ merged += 1
83
+ CLAUDE_JSON.write_text(json.dumps(cj, indent=2, ensure_ascii=False))
84
+ info(f"claude mcp: merged {merged} (skipped {total - merged})")
85
+
86
+ settings = data.get("settings") or {}
87
+ local_s = read_json(CLAUDE_DIR / "settings.json")
88
+ env = settings.get("env") or {}
89
+ skipped = [k for k, v in env.items() if v is None]
90
+ merge(local_s, settings, force)
91
+ (CLAUDE_DIR / "settings.json").parent.mkdir(parents=True, exist_ok=True)
92
+ (CLAUDE_DIR / "settings.json").write_text(json.dumps(local_s, indent=2, ensure_ascii=False))
93
+ if skipped:
94
+ info(f"claude settings: skipped redacted env keys (fill manually): {', '.join(skipped)}")
95
+
96
+ for kind in ("skills", "commands", "agents"):
97
+ n = _copy_assets(bundle / "assets" / "claude" / kind, CLAUDE_DIR / kind, force)
98
+ if n:
99
+ info(f"claude {kind}: copied {n}")
100
+
101
+ plugins = data.get("plugins") or []
102
+ if not plugins:
103
+ return
104
+ if not yes:
105
+ print("# claude plugins (run these, or use --yes):")
106
+ for p in plugins:
107
+ mp = f"@{p['marketplace']}" if p["marketplace"] else ""
108
+ print(f"claude plugin install {p['name']}{mp}")
109
+ return
110
+ for p in plugins:
111
+ mp = f"@{p['marketplace']}" if p["marketplace"] else ""
112
+ cmd = ["claude", "plugin", "install", f"{p['name']}{mp}"]
113
+ info(f" $ {' '.join(cmd)}")
114
+ subprocess.run(cmd)
115
+
116
+
117
+ def apply_cursor(bundle, force=False, yes=False):
118
+ data = read_json(bundle / "cursor_config.json")
119
+ local = read_json(CURSOR_MCP)
120
+ local.setdefault("mcpServers", {})
121
+ merged = 0
122
+ total = 0
123
+ for name, srv in (data.get("mcp") or {}).items():
124
+ total += 1
125
+ if name not in local["mcpServers"] or force:
126
+ local["mcpServers"][name] = srv
127
+ merged += 1
128
+ CURSOR_MCP.parent.mkdir(parents=True, exist_ok=True)
129
+ CURSOR_MCP.write_text(json.dumps(local, indent=2, ensure_ascii=False))
130
+ if total:
131
+ info(f"cursor mcp: merged {merged} (skipped {total - merged})")
132
+
133
+ n = _copy_assets(bundle / "assets" / "cursor" / "rules", CURSOR_DIR / "rules", force)
134
+ if n:
135
+ info(f"cursor rules: copied {n}")
136
+
137
+ settings = data.get("settings") or {}
138
+ if settings:
139
+ local_s = read_json(CURSOR_USER_SETTINGS)
140
+ merge(local_s, settings, force)
141
+ CURSOR_USER_SETTINGS.parent.mkdir(parents=True, exist_ok=True)
142
+ CURSOR_USER_SETTINGS.write_text(json.dumps(local_s, indent=2, ensure_ascii=False))
143
+ info("cursor settings: merged")
144
+
145
+ exts = data.get("extensions") or []
146
+ if not exts:
147
+ return
148
+ if not yes:
149
+ print("# cursor extensions (run these, or use --yes):")
150
+ for e in exts:
151
+ ver = f"@{e['version']}" if e["version"] else ""
152
+ print(f"cursor --install-extension {e['id']}{ver}")
153
+ return
154
+ for e in exts:
155
+ ver = f"@{e['version']}" if e["version"] else ""
156
+ cmd = ["cursor", "--install-extension", f"{e['id']}{ver}"]
157
+ info(f" $ {' '.join(cmd)}")
158
+ subprocess.run(cmd)
159
+
160
+
161
+ def cmd_install(args):
162
+ from pathlib import Path
163
+
164
+ from .bundle import load_manifest
165
+ from .errors import AicsError
166
+
167
+ bundle = Path(args.bundle).expanduser().resolve()
168
+ if not load_manifest(bundle):
169
+ raise AicsError(f"not an aics bundle: {bundle}")
170
+ ts = now_iso().replace(":", "-")
171
+ bdir = backup_current(ts)
172
+ info(f"backup -> {bdir}")
173
+ if args.client in ("claude", None):
174
+ info("[claude]")
175
+ apply_claude(bundle, force=args.force, yes=args.yes)
176
+ if args.client in ("cursor", None):
177
+ info("[cursor]")
178
+ apply_cursor(bundle, force=args.force, yes=args.yes)
aics/log.py ADDED
@@ -0,0 +1,24 @@
1
+ import sys
2
+
3
+ _VERBOSE = False
4
+ _QUIET = False
5
+
6
+
7
+ def set_verbose(v: bool):
8
+ global _VERBOSE
9
+ _VERBOSE = v
10
+
11
+
12
+ def set_quiet(q: bool):
13
+ global _QUIET
14
+ _QUIET = q
15
+
16
+
17
+ def info(msg: str):
18
+ if not _QUIET:
19
+ print(msg, file=sys.stderr)
20
+
21
+
22
+ def debug(msg: str):
23
+ if _VERBOSE and not _QUIET:
24
+ print(msg, file=sys.stderr)
aics/render.py ADDED
@@ -0,0 +1,127 @@
1
+ import json
2
+ import os
3
+ import shlex
4
+
5
+ from .config import HOME
6
+
7
+ _c = {
8
+ "reset": "0",
9
+ "bold": "1",
10
+ "dim": "2",
11
+ "red": "31",
12
+ "green": "32",
13
+ "yellow": "33",
14
+ "blue": "34",
15
+ "magenta": "35",
16
+ "cyan": "36",
17
+ "gray": "90",
18
+ }
19
+
20
+
21
+ def now_iso():
22
+ from datetime import datetime, timezone
23
+
24
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
25
+
26
+
27
+ def host():
28
+ return os.uname().nodename if hasattr(os, "uname") else os.getenv("HOSTNAME", "unknown")
29
+
30
+
31
+ def mcp_cmd_claude(name, srv):
32
+ if srv.get("url"):
33
+ tr = srv.get("type") or "sse"
34
+ return f"claude mcp add --transport {tr} {shlex.quote(name)} {shlex.quote(srv['url'])}"
35
+ cmd = srv.get("command", "")
36
+ args = srv.get("args") or []
37
+ full = " ".join(shlex.quote(x) for x in [cmd] + list(args))
38
+ return f"claude mcp add {shlex.quote(name)} -- {full}"
39
+
40
+
41
+ def mcp_env_note(srv):
42
+ env = srv.get("env") or {}
43
+ keys = [k for k in env if env.get(k) is None or env.get(k) == ""]
44
+ if not keys:
45
+ return ""
46
+ return f" # env to fill: {', '.join(keys)}"
47
+
48
+
49
+ def render_markdown(claude, cursor, redacted=None, guide=False, color=False):
50
+ def h(text, *styles):
51
+ return f"\033[{';'.join(_c[s] for s in styles)}m{text}\033[0m" if color else text
52
+
53
+ lines = []
54
+ if guide:
55
+ lines += [
56
+ h("# AICS Install Guide", "bold", "cyan"),
57
+ "",
58
+ f"Exported: {now_iso()} from host `{host()}`",
59
+ "",
60
+ "An agent can execute this guide section by section.",
61
+ "",
62
+ ]
63
+ redacted = redacted or []
64
+
65
+ lines += [h("## Claude Code", "bold", "cyan"), ""]
66
+ lines += [f"- MCP servers: {len(claude['mcpServers'])}"]
67
+ if claude["mcpServers"]:
68
+ lines += ["", "```bash"]
69
+ for n, s in claude["mcpServers"].items():
70
+ lines.append(mcp_cmd_claude(n, s))
71
+ note = mcp_env_note(s)
72
+ if note:
73
+ lines.append(note)
74
+ lines += ["```"]
75
+ lines += [f"- Plugins: {len(claude['plugins'])}"]
76
+ if claude["plugins"]:
77
+ enabled = claude["enabledPlugins"]
78
+ lines += ["", "```bash"]
79
+ for p in claude["plugins"]:
80
+ mp = f"@{p['marketplace']}" if p["marketplace"] else ""
81
+ lines.append(f"claude plugin install {p['name']}{mp}")
82
+ if not enabled.get(f"{p['name']}@{p['marketplace']}", True):
83
+ lines.append(f" # disabled in source — review before enabling")
84
+ lines += ["```"]
85
+ for kind in ("skills", "commands", "agents"):
86
+ items = claude[kind]
87
+ lines += [f"- {kind}: {len(items)}"]
88
+ if items:
89
+ lines += [""]
90
+ lines += [f" {', '.join(items)}"]
91
+ if guide:
92
+ lines += ["", "```bash", f"cp -r assets/claude/{kind}/* ~/.claude/{kind}/", "```"]
93
+ s = claude["settings"]
94
+ env = (s.get("env") or {}) if s else {}
95
+ model = s.get("model")
96
+ if env or model:
97
+ lines += ["- settings:"]
98
+ if model:
99
+ lines += [f" - model: `{model}`"]
100
+ for k, v in env.items():
101
+ mark = " *(redacted — fill value)*" if v is None else f" = `{v}`"
102
+ lines += [f" - env `{k}`{mark}"]
103
+
104
+ lines += ["", h("## Cursor", "bold", "cyan"), ""]
105
+ lines += [f"- MCP servers: {len(cursor['mcp'])}"]
106
+ if cursor["mcp"]:
107
+ lines += ["Merge into `~/.cursor/mcp.json` under `mcpServers`:", "", "```json"]
108
+ lines.append(json.dumps({"mcpServers": cursor["mcp"]}, indent=2, ensure_ascii=False))
109
+ lines += ["```"]
110
+ lines += [f"- Rules: {len(cursor['rules'])}"]
111
+ if cursor["rules"]:
112
+ lines += [f" {', '.join(cursor['rules'])}"]
113
+ if guide:
114
+ lines += ["", "```bash", "cp -r assets/cursor/rules/* ~/.cursor/rules/", "```"]
115
+ lines += [f"- Extensions: {len(cursor['extensions'])}"]
116
+ if cursor["extensions"]:
117
+ lines += ["", "```bash"]
118
+ for e in cursor["extensions"]:
119
+ ver = f"@{e['version']}" if e["version"] else ""
120
+ lines.append(f"cursor --install-extension {e['id']}{ver}")
121
+ lines += ["```"]
122
+
123
+ if redacted:
124
+ lines += ["", h("## Redacted (fill on target)", "bold", "red"), ""]
125
+ for r in redacted:
126
+ lines.append(f"- `{r['key']}` at `{r['path']}`")
127
+ return "\n".join(lines) + "\n"
aics/sanitize.py ADDED
@@ -0,0 +1,25 @@
1
+ from .config import SECRET_RE
2
+
3
+
4
+ def sanitize(obj, include_secrets=False, path="$"):
5
+ redacted = []
6
+ if isinstance(obj, list):
7
+ out = []
8
+ for i, v in enumerate(obj):
9
+ r = sanitize(v, include_secrets, f"{path}[{i}]")
10
+ out.append(r[0])
11
+ redacted += r[1]
12
+ return out, redacted
13
+ if isinstance(obj, dict):
14
+ out = {}
15
+ for k, v in obj.items():
16
+ p = f"{path}.{k}"
17
+ if not include_secrets and isinstance(v, str) and SECRET_RE.search(k):
18
+ redacted.append({"path": p, "key": k})
19
+ out[k] = None
20
+ else:
21
+ r = sanitize(v, include_secrets, p)
22
+ out[k] = r[0]
23
+ redacted += r[1]
24
+ return out, redacted
25
+ return obj, redacted
aics/scan.py ADDED
@@ -0,0 +1,74 @@
1
+ import json
2
+
3
+ from .config import CLAUDE_DIR, CLAUDE_JSON, CURSOR_DIR, CURSOR_EXT_JSON, CURSOR_MCP, CURSOR_USER_SETTINGS
4
+
5
+
6
+ def read_json(p):
7
+ try:
8
+ return json.loads(p.read_text())
9
+ except Exception:
10
+ return {}
11
+
12
+
13
+ def scan_claude():
14
+ inv = {
15
+ "mcpServers": {},
16
+ "projects": {},
17
+ "settings": {},
18
+ "plugins": [],
19
+ "enabledPlugins": {},
20
+ "skills": [],
21
+ "commands": [],
22
+ "agents": [],
23
+ }
24
+ cj = read_json(CLAUDE_JSON)
25
+ inv["mcpServers"] = cj.get("mcpServers", {}) or {}
26
+ projects = {}
27
+ for pth, pdata in (cj.get("projects") or {}).items():
28
+ ms = (pdata or {}).get("mcpServers") or {}
29
+ if ms:
30
+ projects[pth] = {"mcpServers": ms}
31
+ inv["projects"] = projects
32
+ inv["settings"] = read_json(CLAUDE_DIR / "settings.json")
33
+ inv["enabledPlugins"] = inv["settings"].get("enabledPlugins", {}) or {}
34
+ ip = read_json(CLAUDE_DIR / "plugins" / "installed_plugins.json")
35
+ plugins = []
36
+ for key, installs in (ip.get("plugins") or {}).items():
37
+ if "@" in key:
38
+ name, marketplace = key.split("@", 1)
39
+ else:
40
+ name, marketplace = key, ""
41
+ meta = installs[0] if installs else {}
42
+ plugins.append(
43
+ {
44
+ "name": name,
45
+ "marketplace": marketplace,
46
+ "version": meta.get("version", ""),
47
+ "sha": meta.get("gitCommitSha"),
48
+ }
49
+ )
50
+ inv["plugins"] = plugins
51
+ for kind in ("skills", "commands", "agents"):
52
+ d = CLAUDE_DIR / kind
53
+ inv[kind] = (
54
+ sorted(x.name for x in d.iterdir() if x.is_dir() and not x.name.startswith("."))
55
+ if d.exists()
56
+ else []
57
+ )
58
+ return inv
59
+
60
+
61
+ def scan_cursor():
62
+ inv = {"mcp": {}, "rules": [], "settings": {}, "extensions": []}
63
+ inv["mcp"] = (read_json(CURSOR_MCP) or {}).get("mcpServers", {}) or {}
64
+ rd = CURSOR_DIR / "rules"
65
+ inv["rules"] = sorted(f.name for f in rd.glob("*.mdc")) if rd.exists() else []
66
+ inv["settings"] = read_json(CURSOR_USER_SETTINGS)
67
+ exts = read_json(CURSOR_EXT_JSON)
68
+ out = []
69
+ for e in exts or []:
70
+ eid = (e.get("identifier") or {}).get("id", "")
71
+ if eid:
72
+ out.append({"id": eid, "version": e.get("version", "")})
73
+ inv["extensions"] = out
74
+ return inv
aics/tui.py ADDED
@@ -0,0 +1,78 @@
1
+ import os
2
+ import sys
3
+
4
+ _CODES = {
5
+ "reset": "0",
6
+ "bold": "1",
7
+ "dim": "2",
8
+ "red": "31",
9
+ "green": "32",
10
+ "yellow": "33",
11
+ "blue": "34",
12
+ "magenta": "35",
13
+ "cyan": "36",
14
+ "gray": "90",
15
+ }
16
+
17
+
18
+ def supports(stream=sys.stdout) -> bool:
19
+ return (
20
+ os.environ.get("NO_COLOR") is None
21
+ and stream.isatty()
22
+ and os.environ.get("TERM", "") != "dumb"
23
+ )
24
+
25
+
26
+ def paint(text, *styles, stream=sys.stdout):
27
+ if not supports(stream):
28
+ return text
29
+ codes = ";".join(_CODES[s] for s in styles)
30
+ return f"\033[{codes}m{text}\033[0m"
31
+
32
+
33
+ def header(text, stream=sys.stdout):
34
+ return paint(text, "bold", "cyan", stream=stream)
35
+
36
+
37
+ def confirm(prompt: str) -> bool:
38
+ mark = paint("[y/N]", "yellow")
39
+ try:
40
+ ans = input(f"{prompt} {mark} ").strip().lower()
41
+ except EOFError:
42
+ return False
43
+ return ans in ("y", "yes")
44
+
45
+
46
+ def prompt_default(label: str, default: str) -> str:
47
+ try:
48
+ ans = input(f"{label} [{default}]: ").strip()
49
+ except EOFError:
50
+ return default
51
+ return ans or default
52
+
53
+
54
+ def prompt_required(label: str) -> str:
55
+ while True:
56
+ try:
57
+ ans = input(f"{label}: ").strip()
58
+ except EOFError:
59
+ return ""
60
+ if ans:
61
+ return ans
62
+ print(paint(" required", "red"), file=sys.stderr)
63
+
64
+
65
+ def pick(title: str, options: list) -> int | None:
66
+ print(header(title))
67
+ for i, o in enumerate(options, 1):
68
+ print(f" {paint(str(i), 'green')}) {o}")
69
+ while True:
70
+ try:
71
+ ans = input(paint("> ", "blue")).strip()
72
+ except EOFError:
73
+ return None
74
+ if ans == "":
75
+ return None
76
+ if ans.isdigit() and 1 <= int(ans) <= len(options):
77
+ return int(ans) - 1
78
+ print(paint(" invalid choice", "red"), file=sys.stderr)
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.5
2
+ Name: aicsync
3
+ Version: 0.1.0
4
+ Summary: AI client config sync CLI — back up / migrate Claude Code & Cursor config (MCP, skills, plugins, rules, extensions).
5
+ Project-URL: Homepage, https://github.com/hanjinxin/aics
6
+ Author: hanjinxin
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: backup,claude-code,cli,config,cursor,mcp,sync
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+
19
+ # aics
20
+
21
+ **AI client config sync** — 在不同机器之间备份/迁移你的 Claude Code 与 Cursor 配置(MCP、skills、plugins、commands、agents、rules、extensions)。
22
+
23
+ 纯 Python 标准库,零依赖,零构建。`python3 aics.py` 直接跑。
24
+
25
+ ## 为什么需要
26
+
27
+ 你在 Claude Code 和 Cursor 上攒了一套个人配置——MCP servers、skills、plugins、自定义命令、Cursor rules、扩展。换机器或重装系统时,这些散落在 `~/.claude.json`、`~/.claude/`、`~/.cursor/` 各处的配置没法一键带走,手动拷贝既容易漏,又容易把明文密钥(API token)带走泄露到别处。
28
+
29
+ 市面已有的工具(Smithery、社区 mcp-sync)只管「从零安装 MCP server」,没有「把我这套配置整体打包、到新机器一键还原」的同端迁移工具。aics 填这个空。
30
+
31
+ ## 安装
32
+
33
+ ```bash
34
+ git clone <your-repo> aics && cd aics
35
+ chmod +x aics.py bin/aics
36
+ # 可选:加到 PATH
37
+ ln -s "$(pwd)/bin/aics" /usr/local/bin/aics
38
+ ```
39
+
40
+ 要求:Python 3.10+(仅标准库)。
41
+
42
+ ## 全局选项
43
+
44
+ ```
45
+ -V, --version 版本号
46
+ -v, --verbose 详细日志输出到 stderr
47
+ -q, --quiet 静默(抑制进度日志)
48
+ -h, --help 帮助
49
+ ```
50
+
51
+ 结果走 stdout,日志/错误走 stderr,可安全用于管道。退出码:0 成功,1 失败,130 中断。可用 `AICS_HOME` 环境变量覆盖目标 home(测试/沙箱用)。
52
+
53
+ ## 交互模式
54
+
55
+ aics 是 **CLI + interactive layer**(非全屏 TUI)。TTY 里自动开,管道/agent 走纯文本:
56
+
57
+ - **无参 `aics`**:TTY 进数字菜单(status/export/list/diff/install/convert/quit),逐项收参;非 TTY 打印 help。
58
+ - **颜色**:TTY 时 status/diff/list 自动加色(标题青、`==` 绿、`+bundle` 黄、redacted 红)。设 `NO_COLOR` 或非 TTY → 纯文本。
59
+ - **install 确认门**:TTY 且非 `--yes` 时,先打彩色 diff,再 `Apply these changes? [y/N]`。答 n 中止(不备份不改);答 y 才备份+应用。非 TTY / `--yes` 跳过确认,自动化路径不变。
60
+
61
+ 人类有颜色有确认有菜单,agent / `aics install bundle | bash` / CI 走的仍是干净 stdout + 退出码。
62
+
63
+ ## 命令
64
+
65
+
66
+
67
+ ### `status` — 看本机有什么
68
+
69
+ ```bash
70
+ python3 aics.py status # 两端都看
71
+ python3 aics.py status --client claude
72
+ ```
73
+
74
+ 输出 markdown 清单:每个 MCP server 的安装命令、每个 plugin 的 `claude plugin install` 命令、skills/commands/agents 列表、Cursor 扩展列表。密钥默认脱敏。
75
+
76
+ ### `export` — 打包配置
77
+
78
+ ```bash
79
+ python3 aics.py export -o ./my-bundle # 默认脱敏
80
+ python3 aics.py export -o ./my-bundle --tar # 额外打 tar.gz
81
+ python3 aics.py export -o ./my-bundle --include-secrets # 保留明文密钥
82
+ ```
83
+
84
+ 生成 bundle 目录:
85
+
86
+ ```
87
+ my-bundle/
88
+ ├── INSTALL.md # agent 可读的安装指南(含每项的安装命令)
89
+ ├── manifest.json # 机器可读索引(counts / redacted / assets)
90
+ ├── claude_mcp.json # MCP servers + settings + plugins 清单
91
+ ├── cursor_config.json # MCP + settings + extensions 清单
92
+ └── assets/
93
+ ├── claude/{skills,commands,agents}/ # 纯文本资产,直接拷贝
94
+ └── cursor/rules/ # .mdc 规则文件
95
+ ```
96
+
97
+
98
+
99
+ ### `list` — 看 bundle 内容
100
+
101
+ ```bash
102
+ python3 aics.py list ./my-bundle
103
+ ```
104
+
105
+
106
+
107
+ ### `diff` — bundle vs 本机
108
+
109
+ ```bash
110
+ python3 aics.py diff ./my-bundle
111
+ ```
112
+
113
+ 逐项对比 MCP/plugins/skills/extensions 的数量差异。
114
+
115
+ ### `install` — 应用到本机
116
+
117
+ ```bash
118
+ python3 aics.py install ./my-bundle # 文件类直接应用,网络类只打印
119
+ python3 aics.py install ./my-bundle --client claude
120
+ python3 aics.py install ./my-bundle --force # 覆盖已存在项
121
+ python3 aics.py install ./my-bundle --yes # 连网络安装(plugins/扩展)一起跑
122
+ ```
123
+
124
+ 行为:
125
+
126
+ - **执行前**先备份当前配置到 `~/.aics/backup/<timestamp>/`
127
+ - **文件类**(MCP JSON 合并、skills/commands/agents/rules 拷贝、settings 合并)直接执行,幂等(已存在跳过,`--force` 才覆盖)
128
+ - **网络类**(`claude plugin install`、`cursor --install-extension`)默认只打印命令,`--yes` 才真跑
129
+ - **密钥**:脱敏字段保持空值并提示手动回填,绝不猜值
130
+
131
+
132
+
133
+ ### `convert` — Claude skill → Cursor rule
134
+
135
+ ```bash
136
+ python3 aics.py convert --skill ~/.claude/skills/foo/SKILL.md --out foo.mdc
137
+ ```
138
+
139
+ 把 SKILL.md 的 frontmatter 翻译成 Cursor `.mdc` 格式,无法表达的触发语义打 `<!-- TODO -->`。
140
+
141
+ ## 两条使用路径
142
+
143
+ ```mermaid
144
+ flowchart LR
145
+ A["aics export"] --> B["bundle + INSTALL.md"]
146
+ B --> C{谁来装?}
147
+ C -->|让 CLI 自己装| D["aics install bundle --yes"]
148
+ C -->|交给 agent| E["agent 读 INSTALL.md\n逐段执行命令"]
149
+ B -.->|密钥| F["redacted 列表\n手动回填"]
150
+ ```
151
+
152
+ ## Agent 用法
153
+
154
+ 非 TTY 自动无色、无确认门、无菜单。stdout 只放结果(可 grep/parse),stderr 放进度日志。exit code:0 成功 / 1 失败 / 130 中断。
155
+
156
+ 最小三步:
157
+
158
+ ```bash
159
+ aics export -o b # 打包(密钥默认脱敏,产物 b/INSTALL.md + manifest.json)
160
+ aics install b --yes # 应用(非交互,--yes 连网络安装一起跑,幂等可重试)
161
+ aics diff b # 验证(全 == 即还原完成)
162
+ ```
163
+
164
+ 要点:
165
+ - `aics status` stdout 是 markdown,`claude mcp add ...` / `claude plugin install ...` 行可直接执行。
166
+ - 密钥在 bundle 里是 null,**agent 不猜值**;把 `manifest.json` 的 `redacted` 列表抛给用户回填。
167
+ - 沙箱试跑:`AICS_HOME=/tmp/sb aics install b --yes`,不碰真实 home。
168
+ - `install` 幂等,重复执行跳过已存在项。
169
+
170
+
171
+
172
+
173
+
174
+ ## 设计:插件为什么是「重装」而不是「拷贝」
175
+
176
+ `~/.claude/plugins/cache/` 里的插件缓存含平台编译产物(darwin-x64 二进制)、git 工作树状态、绝对路径,且全部信息可由 `installed_plugins.json` 的 `name@marketplace` + git sha 重建。所以 aics 只导出安装清单,`install` 时跑 `claude plugin install <name>@<marketplace>` 按 sha 精确重装——迁的是「安装意图」,不是「缓存尸体」。
177
+
178
+ skills/commands/agents/rules 是纯文本,直接拷贝,无需重装。
179
+
180
+ ## 安全模型
181
+
182
+ - **默认脱敏**:所有 JSON 值中匹配 `token|key|secret|password|auth|credential` 的字段在 export/status 时置空,记入 `manifest.redacted`
183
+ - `--include-secrets` 显式 opt-in 才保留明文
184
+ - `install` 时不猜密钥值,提示用户手动回填
185
+ - `install` 前自动备份原配置
186
+
187
+
188
+
189
+ ## 客户端适配
190
+
191
+ 目前支持 Claude Code + Cursor。新增客户端只需写一个 scan 函数 + apply 函数。
192
+
193
+ ## License
194
+
195
+ MIT
@@ -0,0 +1,18 @@
1
+ aics/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ aics/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ aics/bundle.py,sha256=h-oMZZh5-0bufB0xgaXVyhDsmYEQ83k04eeIKnH-bGI,5887
4
+ aics/cli.py,sha256=oTPZgLcLgUFku0RlwHBlRffOEuT0IdkDgWq38XRzLBI,6351
5
+ aics/config.py,sha256=GaaBktzcr6XCnumYGIWmLDC0_l6Iz2_LhOI5GQZo9f8,600
6
+ aics/convert.py,sha256=qL68Hot_qyrbz4FnpEduK_8qNaB8l19z0wXDGcuaSwU,1056
7
+ aics/errors.py,sha256=y0FxPT1tJ96daEhEyhabT7jjrH3-YnJvQfeOs1TEV9g,150
8
+ aics/installer.py,sha256=HiOUVVHO8PBkRVq3aZRY_etanWALNp9cmPHNb1uV06E,5860
9
+ aics/log.py,sha256=jqwVsivIJ0Crg34Wx45QL2hMoSux0HH2MVN_3SeL5Ls,336
10
+ aics/render.py,sha256=QWFbCL1gPsIpV6hpUa3YJ3gMe-bnbNPAGxjVY1Empyo,4316
11
+ aics/sanitize.py,sha256=qL4WDGko9iXD3Znp6eFqDxcD6k7Y4TGEG1fXLo87hJ8,798
12
+ aics/scan.py,sha256=mLleBnoi3X1rSNmrU7Q71NWQgaFXBq1ppoLamaB3AS8,2354
13
+ aics/tui.py,sha256=13koTBCtMRaB6BiI6WeEQuSsOjNFV9GgJ0Qk-ZZ3Qeo,1839
14
+ aicsync-0.1.0.dist-info/METADATA,sha256=uuclM1y3nVzJ6q2tUNEymaU1NmoYtLPpeN1RSwYFH6s,7207
15
+ aicsync-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
16
+ aicsync-0.1.0.dist-info/entry_points.txt,sha256=gsKiyQE6L_bGlVyWI3VlU5ZV-PPyqS75jgc2uEU7sCY,39
17
+ aicsync-0.1.0.dist-info/licenses/LICENSE,sha256=kW0a_kmab_OnSUttlChECLFfA1pWdLCbGHDR65PSKvg,1066
18
+ aicsync-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aics = aics.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hanjinxin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.