@dotformat/cli 0.1.1 → 0.2.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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/cli";
package/package.json CHANGED
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "name": "@dotformat/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Collect, compare, and sync machine configs across machines",
5
5
  "type": "module",
6
6
  "bin": {
7
- "dotfiles": "./bin/dotfiles.js"
7
+ "dotfiles": "./bin/dotfiles.ts"
8
+ },
9
+ "scripts": {
10
+ "collect": "bun bin/dotfiles.ts collect",
11
+ "compare": "bun bin/dotfiles.ts compare",
12
+ "test": "bun test"
8
13
  },
9
14
  "files": [
10
- "bin/dotfiles.js",
11
- "setup/collect-machine-config.sh"
15
+ "bin/dotfiles.ts",
16
+ "src/"
12
17
  ],
13
18
  "keywords": [
14
19
  "dotfiles",
@@ -23,5 +28,11 @@
23
28
  "type": "git",
24
29
  "url": "git+https://github.com/doguyilmaz/dotfiles.git"
25
30
  },
26
- "homepage": "https://github.com/doguyilmaz/dotfiles#readme"
31
+ "homepage": "https://github.com/doguyilmaz/dotfiles#readme",
32
+ "dependencies": {
33
+ "@dotformat/core": "^0.2.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/bun": "latest"
37
+ }
27
38
  }
package/src/cli.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { collect } from "./commands/collect";
2
+ import { compareCli } from "./commands/compare";
3
+ import { list } from "./commands/list";
4
+
5
+ const [command, ...args] = Bun.argv.slice(2);
6
+
7
+ switch (command) {
8
+ case "collect":
9
+ await collect(args);
10
+ break;
11
+ case "compare":
12
+ await compareCli(args);
13
+ break;
14
+ case "list":
15
+ await list(args);
16
+ break;
17
+ default:
18
+ console.log(`Usage: dotfiles <command>
19
+
20
+ Commands:
21
+ collect [--no-redact] [-o path] Collect machine config → .dotf report
22
+ compare [file1] [file2] Diff two .dotf reports
23
+ list <section> Print a section from most recent report`);
24
+ }
@@ -0,0 +1,43 @@
1
+ import type { Collector } from "./types";
2
+ import { makeSection } from "./types";
3
+
4
+ export const collectApps: Collector = async () => {
5
+ const result: Record<string, ReturnType<typeof makeSection>> = {};
6
+
7
+ const raycastInstalled = await Bun.file("/Applications/Raycast.app/Contents/Info.plist").exists();
8
+ result["apps.raycast"] = makeSection("apps.raycast", {
9
+ pairs: { installed: raycastInstalled ? "true" : "false" },
10
+ });
11
+
12
+ const alttabInstalled = await Bun.file("/Applications/AltTab.app/Contents/Info.plist").exists();
13
+ const alttabPairs: Record<string, string> = {
14
+ installed: alttabInstalled ? "true" : "false",
15
+ };
16
+
17
+ if (alttabInstalled) {
18
+ try {
19
+ const prefs = await Bun.$`defaults read com.lwouis.alt-tab-macos 2>/dev/null`.text();
20
+ if (prefs.trim()) alttabPairs.preferences = "exists";
21
+ } catch {}
22
+ }
23
+
24
+ result["apps.alttab"] = makeSection("apps.alttab", { pairs: alttabPairs });
25
+
26
+ try {
27
+ const appsOutput = await Bun.$`ls /Applications/`.text();
28
+ const apps = appsOutput
29
+ .trim()
30
+ .split("\n")
31
+ .map((a) => a.trim())
32
+ .filter(Boolean)
33
+ .sort();
34
+
35
+ if (apps.length) {
36
+ result["apps.macos"] = makeSection("apps.macos", {
37
+ items: apps.map((a) => ({ raw: a, columns: [a] })),
38
+ });
39
+ }
40
+ } catch {}
41
+
42
+ return result;
43
+ };
@@ -0,0 +1,14 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectBunConfig: Collector = async (ctx) => {
6
+ const bunfigFile = Bun.file(join(ctx.home, ".bunfig.toml"));
7
+
8
+ if (!(await bunfigFile.exists())) return {};
9
+
10
+ const content = await bunfigFile.text();
11
+ return {
12
+ "bun.config": makeSection("bun.config", { content: content.trim() }),
13
+ };
14
+ };
@@ -0,0 +1,46 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectClaude: Collector = async (ctx) => {
6
+ const result: Record<string, ReturnType<typeof makeSection>> = {};
7
+ const claudeDir = join(ctx.home, ".claude");
8
+
9
+ const settingsFile = Bun.file(join(claudeDir, "settings.json"));
10
+ if (await settingsFile.exists()) {
11
+ try {
12
+ const settings = await settingsFile.json();
13
+ const pairs: Record<string, string> = {};
14
+ if (settings.permissions) {
15
+ for (const [key, val] of Object.entries(settings.permissions)) {
16
+ pairs[key] = String(val);
17
+ }
18
+ }
19
+ if (Object.keys(pairs).length) {
20
+ result["ai.claude.plugins"] = makeSection("ai.claude.plugins", { pairs });
21
+ }
22
+ } catch {}
23
+ }
24
+
25
+ try {
26
+ const skillsDir = join(claudeDir, "skills");
27
+ const glob = new Bun.Glob("*");
28
+ const items: { raw: string; columns: string[] }[] = [];
29
+ for await (const entry of glob.scan(skillsDir)) {
30
+ items.push({ raw: entry, columns: [entry] });
31
+ }
32
+ if (items.length) {
33
+ result["ai.claude.skills"] = makeSection("ai.claude.skills", { items });
34
+ }
35
+ } catch {}
36
+
37
+ const claudeMdFile = Bun.file(join(claudeDir, "CLAUDE.md"));
38
+ if (await claudeMdFile.exists()) {
39
+ const content = await claudeMdFile.text();
40
+ result["file:claude/CLAUDE.md"] = makeSection("file:claude/CLAUDE.md", {
41
+ content: content.trim(),
42
+ });
43
+ }
44
+
45
+ return result;
46
+ };
@@ -0,0 +1,30 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectCursor: Collector = async (ctx) => {
6
+ const result: Record<string, ReturnType<typeof makeSection>> = {};
7
+ const cursorDir = join(ctx.home, ".cursor");
8
+
9
+ const mcpFile = Bun.file(join(cursorDir, "mcp.json"));
10
+ if (await mcpFile.exists()) {
11
+ const content = await mcpFile.text();
12
+ result["ai.cursor.mcp"] = makeSection("ai.cursor.mcp", {
13
+ content: content.trim(),
14
+ });
15
+ }
16
+
17
+ try {
18
+ const skillsDir = join(cursorDir, "skills");
19
+ const glob = new Bun.Glob("*");
20
+ const items: { raw: string; columns: string[] }[] = [];
21
+ for await (const entry of glob.scan(skillsDir)) {
22
+ items.push({ raw: entry, columns: [entry] });
23
+ }
24
+ if (items.length) {
25
+ result["ai.cursor.skills"] = makeSection("ai.cursor.skills", { items });
26
+ }
27
+ } catch {}
28
+
29
+ return result;
30
+ };
@@ -0,0 +1,27 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectEditors: Collector = async (ctx) => {
6
+ const result: Record<string, ReturnType<typeof makeSection>> = {};
7
+
8
+ const zedFile = Bun.file(join(ctx.home, ".config/zed/settings.json"));
9
+ if (await zedFile.exists()) {
10
+ const content = await zedFile.text();
11
+ result["editor.zed"] = makeSection("editor.zed", {
12
+ content: content.trim(),
13
+ });
14
+ }
15
+
16
+ const cursorSettingsFile = Bun.file(
17
+ join(ctx.home, "Library/Application Support/Cursor/User/settings.json")
18
+ );
19
+ if (await cursorSettingsFile.exists()) {
20
+ const content = await cursorSettingsFile.text();
21
+ result["editor.cursor"] = makeSection("editor.cursor", {
22
+ content: content.trim(),
23
+ });
24
+ }
25
+
26
+ return result;
27
+ };
@@ -0,0 +1,44 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectGemini: Collector = async (ctx) => {
6
+ const result: Record<string, ReturnType<typeof makeSection>> = {};
7
+ const geminiDir = join(ctx.home, ".gemini");
8
+
9
+ const settingsFile = Bun.file(join(geminiDir, "settings.json"));
10
+ if (await settingsFile.exists()) {
11
+ try {
12
+ const settings = await settingsFile.json();
13
+ const pairs: Record<string, string> = {};
14
+ for (const [key, val] of Object.entries(settings)) {
15
+ pairs[key] = typeof val === "object" ? JSON.stringify(val) : String(val);
16
+ }
17
+ if (Object.keys(pairs).length) {
18
+ result["ai.gemini.settings"] = makeSection("ai.gemini.settings", { pairs });
19
+ }
20
+ } catch {}
21
+ }
22
+
23
+ try {
24
+ const skillsDir = join(geminiDir, "skills");
25
+ const glob = new Bun.Glob("*");
26
+ const items: { raw: string; columns: string[] }[] = [];
27
+ for await (const entry of glob.scan(skillsDir)) {
28
+ items.push({ raw: entry, columns: [entry] });
29
+ }
30
+ if (items.length) {
31
+ result["ai.gemini.skills"] = makeSection("ai.gemini.skills", { items });
32
+ }
33
+ } catch {}
34
+
35
+ const geminiMdFile = Bun.file(join(geminiDir, "GEMINI.md"));
36
+ if (await geminiMdFile.exists()) {
37
+ const content = await geminiMdFile.text();
38
+ result["file:gemini/GEMINI.md"] = makeSection("file:gemini/GEMINI.md", {
39
+ content: content.trim(),
40
+ });
41
+ }
42
+
43
+ return result;
44
+ };
@@ -0,0 +1,14 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectGh: Collector = async (ctx) => {
6
+ const configFile = Bun.file(join(ctx.home, ".config/gh/config.yml"));
7
+
8
+ if (!(await configFile.exists())) return {};
9
+
10
+ const content = await configFile.text();
11
+ return {
12
+ "gh.config": makeSection("gh.config", { content: content.trim() }),
13
+ };
14
+ };
@@ -0,0 +1,16 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectGit: Collector = async (ctx) => {
6
+ const gitconfigFile = Bun.file(join(ctx.home, ".gitconfig"));
7
+
8
+ if (!(await gitconfigFile.exists())) return {};
9
+
10
+ const content = await gitconfigFile.text();
11
+ return {
12
+ "file:git/gitconfig": makeSection("file:git/gitconfig", {
13
+ content: content.trim(),
14
+ }),
15
+ };
16
+ };
@@ -0,0 +1,40 @@
1
+ import type { Collector } from "./types";
2
+ import { makeSection } from "./types";
3
+
4
+ export const collectHomebrew: Collector = async () => {
5
+ const result: Record<string, ReturnType<typeof makeSection>> = {};
6
+
7
+ try {
8
+ const formulaeOutput = await Bun.$`brew list --formula`.text();
9
+ const formulae = formulaeOutput
10
+ .trim()
11
+ .split("\n")
12
+ .map((f) => f.trim())
13
+ .filter(Boolean)
14
+ .sort();
15
+
16
+ if (formulae.length) {
17
+ result["apps.brew.formulae"] = makeSection("apps.brew.formulae", {
18
+ items: formulae.map((f) => ({ raw: f, columns: [f] })),
19
+ });
20
+ }
21
+ } catch {}
22
+
23
+ try {
24
+ const casksOutput = await Bun.$`brew list --cask`.text();
25
+ const casks = casksOutput
26
+ .trim()
27
+ .split("\n")
28
+ .map((c) => c.trim())
29
+ .filter(Boolean)
30
+ .sort();
31
+
32
+ if (casks.length) {
33
+ result["apps.brew.casks"] = makeSection("apps.brew.casks", {
34
+ items: casks.map((c) => ({ raw: c, columns: [c] })),
35
+ });
36
+ }
37
+ } catch {}
38
+
39
+ return result;
40
+ };
@@ -0,0 +1,15 @@
1
+ import { hostname } from "os";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectMeta: Collector = async () => {
6
+ const host = hostname();
7
+ const os = `${Bun.env.OSTYPE ?? (await Bun.$`uname -s`.text()).trim()} ${(await Bun.$`uname -m`.text()).trim()}`;
8
+ const date = new Date().toISOString().split("T")[0];
9
+
10
+ return {
11
+ meta: makeSection("meta", {
12
+ pairs: { host, os, date },
13
+ }),
14
+ };
15
+ };
@@ -0,0 +1,18 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+ import { redactNpmTokens } from "../utils/redact";
5
+
6
+ export const collectNpm: Collector = async (ctx) => {
7
+ const npmrcPath = join(ctx.home, ".npmrc");
8
+ const file = Bun.file(npmrcPath);
9
+
10
+ if (!(await file.exists())) return {};
11
+
12
+ let content = await file.text();
13
+ if (ctx.redact) content = redactNpmTokens(content);
14
+
15
+ return {
16
+ "npm.config": makeSection("npm.config", { content: content.trim() }),
17
+ };
18
+ };
@@ -0,0 +1,26 @@
1
+ import type { Collector } from "./types";
2
+ import { makeSection } from "./types";
3
+
4
+ export const collectOllama: Collector = async () => {
5
+ try {
6
+ const output = await Bun.$`ollama list`.text();
7
+ const lines = output.trim().split("\n");
8
+
9
+ if (lines.length <= 1) return {};
10
+
11
+ const items = lines.slice(1).map((line) => {
12
+ const parts = line.split(/\s{2,}/).map((s) => s.trim()).filter(Boolean);
13
+ const [name, id, size, modified] = parts;
14
+ const raw = [name, size, modified].filter(Boolean).join(" | ");
15
+ return { raw, columns: [name, size, modified].filter(Boolean) };
16
+ }).filter((item) => item.columns.length > 0);
17
+
18
+ if (!items.length) return {};
19
+
20
+ return {
21
+ "ai.ollama.models": makeSection("ai.ollama.models", { items }),
22
+ };
23
+ } catch {
24
+ return {};
25
+ }
26
+ };
@@ -0,0 +1,16 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectShell: Collector = async (ctx) => {
6
+ const zshrcFile = Bun.file(join(ctx.home, ".zshrc"));
7
+
8
+ if (!(await zshrcFile.exists())) return {};
9
+
10
+ const content = await zshrcFile.text();
11
+ return {
12
+ "file:shell/zshrc": makeSection("file:shell/zshrc", {
13
+ content: content.trim(),
14
+ }),
15
+ };
16
+ };
@@ -0,0 +1,61 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+ import { redactSshConfig, redactIPs } from "../utils/redact";
5
+
6
+ interface SshHost {
7
+ host: string;
8
+ hostname: string;
9
+ identityFile: string;
10
+ }
11
+
12
+ function parseSshConfig(content: string): SshHost[] {
13
+ const hosts: SshHost[] = [];
14
+ let current: Partial<SshHost> | null = null;
15
+
16
+ for (const line of content.split("\n")) {
17
+ const trimmed = line.trim();
18
+ if (!trimmed || trimmed.startsWith("#")) continue;
19
+
20
+ const hostMatch = trimmed.match(/^Host\s+(.+)/i);
21
+ if (hostMatch) {
22
+ if (current?.host) hosts.push(current as SshHost);
23
+ current = { host: hostMatch[1].trim(), hostname: "", identityFile: "" };
24
+ continue;
25
+ }
26
+
27
+ if (!current) continue;
28
+
29
+ const hostnameMatch = trimmed.match(/^HostName\s+(.+)/i);
30
+ if (hostnameMatch) current.hostname = hostnameMatch[1].trim();
31
+
32
+ const identityMatch = trimmed.match(/^IdentityFile\s+(.+)/i);
33
+ if (identityMatch) current.identityFile = identityMatch[1].trim();
34
+ }
35
+
36
+ if (current?.host) hosts.push(current as SshHost);
37
+ return hosts;
38
+ }
39
+
40
+ export const collectSsh: Collector = async (ctx) => {
41
+ const configPath = join(ctx.home, ".ssh/config");
42
+ const file = Bun.file(configPath);
43
+
44
+ if (!(await file.exists())) return {};
45
+
46
+ const content = await file.text();
47
+ const hosts = parseSshConfig(content);
48
+
49
+ if (!hosts.length) return {};
50
+
51
+ const items = hosts.map((h) => {
52
+ const hn = ctx.redact ? "[REDACTED]" : h.hostname;
53
+ const id = ctx.redact ? "[REDACTED]" : h.identityFile;
54
+ const raw = `${h.host} | ${hn} | ${id}`;
55
+ return { raw, columns: [h.host, hn, id] };
56
+ });
57
+
58
+ return {
59
+ "ssh.hosts": makeSection("ssh.hosts", { items }),
60
+ };
61
+ };
@@ -0,0 +1,18 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectTerminal: Collector = async (ctx) => {
6
+ const p10kFile = Bun.file(join(ctx.home, ".p10k.zsh"));
7
+
8
+ if (!(await p10kFile.exists())) return {};
9
+
10
+ const content = await p10kFile.text();
11
+ const lineCount = content.split("\n").length;
12
+
13
+ return {
14
+ "terminal.p10k": makeSection("terminal.p10k", {
15
+ pairs: { exists: "true", lines: String(lineCount) },
16
+ }),
17
+ };
18
+ };
@@ -0,0 +1,26 @@
1
+ import type { DotfSection } from "@dotformat/core";
2
+
3
+ export interface CollectorContext {
4
+ redact: boolean;
5
+ home: string;
6
+ }
7
+
8
+ export type CollectorResult = Record<string, DotfSection>;
9
+
10
+ export type Collector = (ctx: CollectorContext) => Promise<CollectorResult>;
11
+
12
+ export function makeSection(
13
+ name: string,
14
+ opts: {
15
+ pairs?: Record<string, string>;
16
+ items?: { raw: string; columns: string[] }[];
17
+ content?: string | null;
18
+ } = {}
19
+ ): DotfSection {
20
+ return {
21
+ name,
22
+ pairs: opts.pairs ?? {},
23
+ items: opts.items ?? [],
24
+ content: opts.content ?? null,
25
+ };
26
+ }
@@ -0,0 +1,30 @@
1
+ import { join } from "path";
2
+ import type { Collector } from "./types";
3
+ import { makeSection } from "./types";
4
+
5
+ export const collectWindsurf: Collector = async (ctx) => {
6
+ const result: Record<string, ReturnType<typeof makeSection>> = {};
7
+ const windsurfDir = join(ctx.home, ".codeium/windsurf");
8
+
9
+ const mcpFile = Bun.file(join(windsurfDir, "mcp_config.json"));
10
+ if (await mcpFile.exists()) {
11
+ const content = await mcpFile.text();
12
+ result["ai.windsurf.mcp"] = makeSection("ai.windsurf.mcp", {
13
+ content: content.trim(),
14
+ });
15
+ }
16
+
17
+ try {
18
+ const skillsDir = join(windsurfDir, "skills");
19
+ const glob = new Bun.Glob("*");
20
+ const items: { raw: string; columns: string[] }[] = [];
21
+ for await (const entry of glob.scan(skillsDir)) {
22
+ items.push({ raw: entry, columns: [entry] });
23
+ }
24
+ if (items.length) {
25
+ result["ai.windsurf.skills"] = makeSection("ai.windsurf.skills", { items });
26
+ }
27
+ } catch {}
28
+
29
+ return result;
30
+ };
@@ -0,0 +1,83 @@
1
+ import { hostname } from "os";
2
+ import { join } from "path";
3
+ import { stringify } from "@dotformat/core";
4
+ import type { DotfDocument } from "@dotformat/core";
5
+ import type { CollectorContext, CollectorResult } from "../collectors/types";
6
+ import { collectMeta } from "../collectors/meta";
7
+ import { collectClaude } from "../collectors/claude";
8
+ import { collectCursor } from "../collectors/cursor";
9
+ import { collectGemini } from "../collectors/gemini";
10
+ import { collectWindsurf } from "../collectors/windsurf";
11
+ import { collectOllama } from "../collectors/ollama";
12
+ import { collectShell } from "../collectors/shell";
13
+ import { collectGit } from "../collectors/git";
14
+ import { collectGh } from "../collectors/gh";
15
+ import { collectEditors } from "../collectors/editors";
16
+ import { collectTerminal } from "../collectors/terminal";
17
+ import { collectSsh } from "../collectors/ssh";
18
+ import { collectNpm } from "../collectors/npm";
19
+ import { collectBunConfig } from "../collectors/bun-config";
20
+ import { collectApps } from "../collectors/apps";
21
+ import { collectHomebrew } from "../collectors/homebrew";
22
+
23
+ const collectors = [
24
+ collectMeta,
25
+ collectClaude,
26
+ collectCursor,
27
+ collectGemini,
28
+ collectWindsurf,
29
+ collectOllama,
30
+ collectShell,
31
+ collectGit,
32
+ collectGh,
33
+ collectEditors,
34
+ collectTerminal,
35
+ collectSsh,
36
+ collectNpm,
37
+ collectBunConfig,
38
+ collectApps,
39
+ collectHomebrew,
40
+ ];
41
+
42
+ function parseArgs(args: string[]) {
43
+ let redact = true;
44
+ let outputDir: string | null = null;
45
+
46
+ for (let i = 0; i < args.length; i++) {
47
+ if (args[i] === "--no-redact") redact = false;
48
+ if (args[i] === "-o" && args[i + 1]) outputDir = args[++i];
49
+ }
50
+
51
+ return { redact, outputDir };
52
+ }
53
+
54
+ export async function collect(args: string[]) {
55
+ const { redact, outputDir } = parseArgs(args);
56
+
57
+ const repoRoot = join(import.meta.dir, "../..");
58
+ const resolvedOutput =
59
+ outputDir ?? join(repoRoot, "reports");
60
+
61
+ await Bun.$`mkdir -p ${resolvedOutput}`.quiet();
62
+
63
+ const ctx: CollectorContext = {
64
+ redact,
65
+ home: Bun.env.HOME ?? "/tmp",
66
+ };
67
+
68
+ const sections: CollectorResult = {};
69
+
70
+ for (const collector of collectors) {
71
+ const result = await collector(ctx);
72
+ Object.assign(sections, result);
73
+ }
74
+
75
+ const doc: DotfDocument = { sections };
76
+ const output = stringify(doc);
77
+
78
+ const filename = `${hostname()}.dotf`;
79
+ const filepath = join(resolvedOutput, filename);
80
+ await Bun.write(filepath, output);
81
+
82
+ console.log(`Report saved to: ${filepath}`);
83
+ }
@@ -0,0 +1,50 @@
1
+ import { join } from "path";
2
+ import { parse, compare, formatDiff } from "@dotformat/core";
3
+
4
+ export async function compareCli(args: string[]) {
5
+ const reportsDir = join(import.meta.dir, "../../reports");
6
+
7
+ let files: string[];
8
+
9
+ if (args.length >= 2) {
10
+ files = args.slice(0, 2);
11
+ } else {
12
+ const glob = new Bun.Glob("*.dotf");
13
+ const entries: { path: string; mtime: number }[] = [];
14
+
15
+ for await (const path of glob.scan(reportsDir)) {
16
+ const stat = await Bun.file(join(reportsDir, path)).stat();
17
+ entries.push({ path: join(reportsDir, path), mtime: stat?.mtimeMs ?? 0 });
18
+ }
19
+
20
+ entries.sort((a, b) => b.mtime - a.mtime);
21
+
22
+ if (entries.length < 2) {
23
+ console.log("Need at least 2 .dotf reports in reports/ to compare.");
24
+ console.log("Usage: dotfiles compare [file1] [file2]");
25
+ return;
26
+ }
27
+
28
+ files = [entries[0].path, entries[1].path];
29
+ }
30
+
31
+ const [leftContent, rightContent] = await Promise.all([
32
+ Bun.file(files[0]).text(),
33
+ Bun.file(files[1]).text(),
34
+ ]);
35
+
36
+ const left = parse(leftContent);
37
+ const right = parse(rightContent);
38
+ const diff = compare(left, right);
39
+
40
+ const leftLabel = files[0].split("/").pop()?.replace(".dotf", "") ?? "left";
41
+ const rightLabel = files[1].split("/").pop()?.replace(".dotf", "") ?? "right";
42
+
43
+ const output = formatDiff(diff, { leftLabel, rightLabel, color: true });
44
+
45
+ if (!output.trim()) {
46
+ console.log("No differences found.");
47
+ } else {
48
+ console.log(output);
49
+ }
50
+ }
@@ -0,0 +1,55 @@
1
+ import { join } from "path";
2
+ import { parse, stringify } from "@dotformat/core";
3
+ import type { DotfDocument } from "@dotformat/core";
4
+
5
+ function fuzzyMatch(query: string, sectionName: string): boolean {
6
+ const q = query.toLowerCase();
7
+ const s = sectionName.toLowerCase();
8
+ return s.includes(q) || s.split(".").some((part) => part.includes(q));
9
+ }
10
+
11
+ export async function list(args: string[]) {
12
+ if (!args.length) {
13
+ console.log("Usage: dotfiles list <section>");
14
+ console.log('Example: dotfiles list brew');
15
+ return;
16
+ }
17
+
18
+ const query = args[0];
19
+ const reportsDir = join(import.meta.dir, "../../reports");
20
+
21
+ const glob = new Bun.Glob("*.dotf");
22
+ const entries: { path: string; mtime: number }[] = [];
23
+
24
+ for await (const path of glob.scan(reportsDir)) {
25
+ const stat = await Bun.file(join(reportsDir, path)).stat();
26
+ entries.push({ path: join(reportsDir, path), mtime: stat?.mtimeMs ?? 0 });
27
+ }
28
+
29
+ entries.sort((a, b) => b.mtime - a.mtime);
30
+
31
+ if (!entries.length) {
32
+ console.log("No .dotf reports found. Run 'dotfiles collect' first.");
33
+ return;
34
+ }
35
+
36
+ const content = await Bun.file(entries[0].path).text();
37
+ const doc = parse(content);
38
+
39
+ const matches = Object.keys(doc.sections).filter((name) =>
40
+ fuzzyMatch(query, name)
41
+ );
42
+
43
+ if (!matches.length) {
44
+ console.log(`No sections matching "${query}".`);
45
+ console.log("Available sections:", Object.keys(doc.sections).join(", "));
46
+ return;
47
+ }
48
+
49
+ for (const name of matches) {
50
+ const sectionDoc: DotfDocument = {
51
+ sections: { [name]: doc.sections[name] },
52
+ };
53
+ console.log(stringify(sectionDoc));
54
+ }
55
+ }
@@ -0,0 +1,22 @@
1
+ const IP_PATTERN = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g;
2
+ const AUTH_TOKEN_PATTERN = /(_authToken=).+/g;
3
+ const SSH_HOSTNAME_PATTERN = /(HostName\s+).+/g;
4
+ const SSH_IDENTITY_PATTERN = /(IdentityFile\s+).+/g;
5
+
6
+ export function redactIPs(text: string): string {
7
+ return text.replace(IP_PATTERN, "[REDACTED]");
8
+ }
9
+
10
+ export function redactNpmTokens(text: string): string {
11
+ return text.replace(AUTH_TOKEN_PATTERN, "$1[REDACTED]");
12
+ }
13
+
14
+ export function redactSshConfig(text: string): string {
15
+ return text
16
+ .replace(SSH_HOSTNAME_PATTERN, "$1[REDACTED]")
17
+ .replace(SSH_IDENTITY_PATTERN, "$1[REDACTED]");
18
+ }
19
+
20
+ export function redactAll(text: string): string {
21
+ return redactIPs(redactNpmTokens(redactSshConfig(text)));
22
+ }
package/bin/dotfiles.js DELETED
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env node
2
- const { execFileSync } = require("child_process");
3
- const { join } = require("path");
4
-
5
- execFileSync("bash", [join(__dirname, "..", "setup", "collect-machine-config.sh")], { stdio: "inherit" });
@@ -1,188 +0,0 @@
1
- #!/bin/bash
2
- # Collects AI tool configs and system info from any machine.
3
- # Run on work Mac, then copy the output file back to compare.
4
- #
5
- # Usage:
6
- # bash collect-machine-config.sh # outputs to ./reports/ if in repo, else ~/
7
- # bash collect-machine-config.sh -o /path # custom output directory
8
-
9
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
- REPO_ROOT="$(dirname "$SCRIPT_DIR")"
11
- HOSTNAME="$(hostname -s)"
12
- FILENAME="dotfiles-report-${HOSTNAME}.md"
13
-
14
- OUTPUT_DIR=""
15
- while getopts "o:" opt; do
16
- case $opt in
17
- o) OUTPUT_DIR="$OPTARG" ;;
18
- *) ;;
19
- esac
20
- done
21
-
22
- if [ -n "$OUTPUT_DIR" ]; then
23
- mkdir -p "$OUTPUT_DIR"
24
- elif [ -d "$REPO_ROOT/reports" ] || [ -d "$REPO_ROOT/.git" ]; then
25
- OUTPUT_DIR="$REPO_ROOT/reports"
26
- mkdir -p "$OUTPUT_DIR"
27
- else
28
- OUTPUT_DIR="$HOME"
29
- fi
30
-
31
- REPORT="$OUTPUT_DIR/$FILENAME"
32
-
33
- {
34
- echo "# Machine Config Report"
35
- echo "- **Host:** $HOSTNAME"
36
- echo "- **OS:** $(uname -s) $(uname -m)"
37
- echo "- **Date:** $(date +%Y-%m-%d)"
38
- echo ""
39
-
40
- echo "## Claude Code"
41
- echo "### settings.json"
42
- echo '```json'
43
- cat ~/.claude/settings.json 2>/dev/null || echo "(not found)"
44
- echo '```'
45
- echo "### CLAUDE.md"
46
- echo '```markdown'
47
- cat ~/.claude/CLAUDE.md 2>/dev/null || echo "(not found)"
48
- echo '```'
49
- echo ""
50
-
51
- echo "## Cursor"
52
- echo "### mcp.json"
53
- echo '```json'
54
- cat ~/.cursor/mcp.json 2>/dev/null || echo "(not found)"
55
- echo '```'
56
- echo "### Skills"
57
- echo '```'
58
- ls ~/.cursor/skills/ 2>/dev/null || echo "(not found)"
59
- echo '```'
60
- echo ""
61
-
62
- echo "## Gemini CLI"
63
- echo "### settings.json"
64
- echo '```json'
65
- cat ~/.gemini/settings.json 2>/dev/null || echo "(not found)"
66
- echo '```'
67
- echo "### GEMINI.md"
68
- echo '```markdown'
69
- cat ~/.gemini/GEMINI.md 2>/dev/null || echo "(not found)"
70
- echo '```'
71
- echo "### Skills"
72
- echo '```'
73
- ls ~/.gemini/skills/ 2>/dev/null || echo "(not found)"
74
- echo '```'
75
- echo ""
76
-
77
- echo "## Windsurf"
78
- echo "### mcp_config.json"
79
- echo '```json'
80
- cat ~/.codeium/windsurf/mcp_config.json 2>/dev/null || echo "(not found)"
81
- echo '```'
82
- echo "### Skills"
83
- echo '```'
84
- ls ~/.codeium/windsurf/skills/ 2>/dev/null || echo "(not found)"
85
- echo '```'
86
- echo ""
87
-
88
- echo "## Shell"
89
- echo "### .zshrc"
90
- echo '```bash'
91
- cat ~/.zshrc 2>/dev/null || echo "(not found)"
92
- echo '```'
93
- echo ""
94
-
95
- echo "## Git"
96
- echo "### .gitconfig"
97
- echo '```ini'
98
- cat ~/.gitconfig 2>/dev/null || echo "(not found)"
99
- echo '```'
100
- echo ""
101
-
102
- echo "## Editors"
103
- echo "### Zed settings.json"
104
- echo '```json'
105
- cat ~/.config/zed/settings.json 2>/dev/null || echo "(not found)"
106
- echo '```'
107
- echo "### Cursor editor settings"
108
- echo '```json'
109
- cat ~/Library/Application\ Support/Cursor/User/settings.json 2>/dev/null || echo "(not found)"
110
- echo '```'
111
- echo ""
112
-
113
- echo "## Terminal"
114
- echo "### p10k theme"
115
- echo '```'
116
- [ -f ~/.p10k.zsh ] && echo "(exists, $(wc -l < ~/.p10k.zsh) lines)" || echo "(not found)"
117
- echo '```'
118
- echo ""
119
-
120
- echo "## SSH Config"
121
- echo '```'
122
- if [ -f ~/.ssh/config ]; then
123
- # Redact IP addresses and key paths for safety
124
- sed 's/HostName .*/HostName [REDACTED]/; s/IdentityFile .*/IdentityFile [REDACTED]/' ~/.ssh/config
125
- else
126
- echo "(not found)"
127
- fi
128
- echo '```'
129
- echo ""
130
-
131
- echo "## GitHub CLI"
132
- echo '```yaml'
133
- cat ~/.config/gh/config.yml 2>/dev/null || echo "(not found)"
134
- echo '```'
135
- echo ""
136
-
137
- echo "## npm config (.npmrc)"
138
- echo '```'
139
- if [ -f ~/.npmrc ]; then
140
- # Redact auth tokens
141
- sed 's/_authToken=.*/_authToken=[REDACTED]/' ~/.npmrc
142
- else
143
- echo "(not found)"
144
- fi
145
- echo '```'
146
- echo ""
147
-
148
- echo "## Bun config (bunfig.toml)"
149
- echo '```toml'
150
- cat ~/.bunfig.toml 2>/dev/null || echo "(not found)"
151
- echo '```'
152
- echo ""
153
-
154
- echo "## Raycast"
155
- echo '```'
156
- [ -d "/Applications/Raycast.app" ] && echo "(installed)" || echo "(not installed)"
157
- echo '```'
158
- echo ""
159
-
160
- echo "## AltTab"
161
- echo '```'
162
- if [ -d "/Applications/AltTab.app" ]; then
163
- echo "(installed)"
164
- defaults read com.lwouis.alt-tab-macos 2>/dev/null | head -30 || echo "(no preferences found)"
165
- else
166
- echo "(not installed)"
167
- fi
168
- echo '```'
169
- echo ""
170
-
171
- echo "## macOS Apps (/Applications)"
172
- echo '```'
173
- ls /Applications/ 2>/dev/null | sort || echo "(unable to list)"
174
- echo '```'
175
- echo ""
176
-
177
- echo "## Homebrew"
178
- echo '```'
179
- brew list --formula 2>/dev/null | sort || echo "(brew not found)"
180
- echo '```'
181
- echo "### Casks"
182
- echo '```'
183
- brew list --cask 2>/dev/null | sort || echo "(brew not found)"
184
- echo '```'
185
-
186
- } > "$REPORT"
187
-
188
- echo "Report saved to: $REPORT"