easymd-cli 0.1.1 → 0.1.3
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/bin/easymd.js +7 -0
- package/package.json +1 -1
- package/src/cli/hook-install.js +64 -0
- package/src/cli/hooks/easymd-stop-sync.sh +56 -0
- package/src/cli/sync.js +21 -1
package/bin/easymd.js
CHANGED
|
@@ -8,6 +8,7 @@ import { syncDir, watchDir } from '../src/cli/sync.js';
|
|
|
8
8
|
import { autoOn, autoOff, autoStatus } from '../src/cli/auto.js';
|
|
9
9
|
import { getCredentials } from '../src/cli/config.js';
|
|
10
10
|
import { mcpInstall } from '../src/cli/mcp-install.js';
|
|
11
|
+
import { hookInstall, hookUninstall } from '../src/cli/hook-install.js';
|
|
11
12
|
|
|
12
13
|
const HELP = `
|
|
13
14
|
easymd — collaborate on markdown files in your repo, live with humans and AI agents
|
|
@@ -26,6 +27,8 @@ Usage:
|
|
|
26
27
|
|
|
27
28
|
easymd mcp Run the MCP server (stdio) so AI agents can edit your docs
|
|
28
29
|
easymd mcp-install [agent] Register the MCP server with Cursor / Claude (agent: --cursor | --claude-desktop)
|
|
30
|
+
easymd hook-install Auto-sync .md to your account after every Claude Code session (everywhere)
|
|
31
|
+
easymd hook-uninstall Remove the auto-sync hook
|
|
29
32
|
|
|
30
33
|
easymd open <file> Open a local .md for real-time collaborative editing in the browser
|
|
31
34
|
easymd open <file> --port N Use a fixed port (default: random)
|
|
@@ -164,6 +167,10 @@ async function main() {
|
|
|
164
167
|
return;
|
|
165
168
|
case 'mcp-install':
|
|
166
169
|
return mcpInstall(rest[0]);
|
|
170
|
+
case 'hook-install':
|
|
171
|
+
return hookInstall();
|
|
172
|
+
case 'hook-uninstall':
|
|
173
|
+
return hookUninstall();
|
|
167
174
|
case 'open':
|
|
168
175
|
return cmdOpen(rest);
|
|
169
176
|
default:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "easymd-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Google Docs for markdown — collaborate on the actual .md file in your repo, live with humans and AI agents. CLI: login, auto-sync, and open .md files for real-time editing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { mkdir, readFile, writeFile, copyFile } from 'fs/promises';
|
|
5
|
+
|
|
6
|
+
const CLAUDE_DIR = join(homedir(), '.claude');
|
|
7
|
+
const SETTINGS = join(CLAUDE_DIR, 'settings.json');
|
|
8
|
+
const HOOK_PATH = fileURLToPath(new URL('./hooks/easymd-stop-sync.sh', import.meta.url));
|
|
9
|
+
const HOOK_CMD = `bash ${HOOK_PATH}`;
|
|
10
|
+
const MARKER = 'easymd-stop-sync';
|
|
11
|
+
|
|
12
|
+
async function readSettings() {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(await readFile(SETTINGS, 'utf8'));
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function writeSettings(s) {
|
|
20
|
+
await mkdir(CLAUDE_DIR, { recursive: true });
|
|
21
|
+
// Back up first — never clobber an existing config silently.
|
|
22
|
+
try {
|
|
23
|
+
await copyFile(SETTINGS, `${SETTINGS}.easymd-bak`);
|
|
24
|
+
} catch {
|
|
25
|
+
/* no existing file */
|
|
26
|
+
}
|
|
27
|
+
await writeFile(SETTINGS, JSON.stringify(s, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Registers a Claude Code Stop hook so that after any session that edits .md files,
|
|
31
|
+
// they auto-sync to your easymd account — across every project, everywhere. Merges
|
|
32
|
+
// alongside any existing Stop hooks (e.g. the obsidian-wiki capture hook).
|
|
33
|
+
export async function hookInstall() {
|
|
34
|
+
const s = await readSettings();
|
|
35
|
+
s.hooks = s.hooks || {};
|
|
36
|
+
s.hooks.Stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : [];
|
|
37
|
+
|
|
38
|
+
if (JSON.stringify(s.hooks.Stop).includes(MARKER)) {
|
|
39
|
+
console.log('✓ easymd auto-sync hook is already installed.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
s.hooks.Stop.push({ matcher: '', hooks: [{ type: 'command', command: HOOK_CMD }] });
|
|
43
|
+
await writeSettings(s);
|
|
44
|
+
console.log('✓ Installed the easymd auto-sync hook in ~/.claude/settings.json');
|
|
45
|
+
console.log(' After any Claude Code session that edits .md files, they sync to your account.');
|
|
46
|
+
console.log(' Requires `easymd login`. A backup was saved to settings.json.easymd-bak.');
|
|
47
|
+
console.log(' Remove anytime with `easymd hook-uninstall`.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function hookUninstall() {
|
|
51
|
+
const s = await readSettings();
|
|
52
|
+
if (!s.hooks?.Stop?.length) {
|
|
53
|
+
console.log('easymd auto-sync hook is not installed.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const before = s.hooks.Stop.length;
|
|
57
|
+
s.hooks.Stop = s.hooks.Stop.filter((e) => !JSON.stringify(e).includes(MARKER));
|
|
58
|
+
if (s.hooks.Stop.length === before) {
|
|
59
|
+
console.log('easymd auto-sync hook is not installed.');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await writeSettings(s);
|
|
63
|
+
console.log('✓ Removed the easymd auto-sync hook.');
|
|
64
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# easymd auto-sync Stop hook (adapted from the obsidian-wiki Stop-capture pattern).
|
|
3
|
+
#
|
|
4
|
+
# Fires on the Claude Code Stop event. If the session edited any .md files and you're
|
|
5
|
+
# logged into easymd, it pushes those docs to your account — so your markdown stays
|
|
6
|
+
# updated in easymd after every agent session, everywhere, with no daemon running.
|
|
7
|
+
#
|
|
8
|
+
# Always exits 0 (silent): it runs the sync directly, it never nudges Claude.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
INPUT=$(cat)
|
|
12
|
+
|
|
13
|
+
# Need credentials — no-op if not logged in.
|
|
14
|
+
[[ -f "$HOME/.easymd/credentials.json" ]] || exit 0
|
|
15
|
+
|
|
16
|
+
# Pull cwd + transcript path from the hook payload.
|
|
17
|
+
read -r CWD TRANSCRIPT < <(printf '%s' "$INPUT" | python3 -c "
|
|
18
|
+
import json, sys
|
|
19
|
+
d = json.load(sys.stdin)
|
|
20
|
+
print(d.get('cwd', ''), d.get('transcript_path', ''))
|
|
21
|
+
" 2>/dev/null || echo " ")
|
|
22
|
+
[[ -n "$TRANSCRIPT" && -f "$TRANSCRIPT" ]] || exit 0
|
|
23
|
+
|
|
24
|
+
# Did this session write/edit any .md files?
|
|
25
|
+
EDITED_MD=$(python3 - "$TRANSCRIPT" <<'PYEOF'
|
|
26
|
+
import json, sys
|
|
27
|
+
n = 0
|
|
28
|
+
for line in open(sys.argv[1]):
|
|
29
|
+
line = line.strip()
|
|
30
|
+
if not line:
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
e = json.loads(line)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
continue
|
|
36
|
+
m = e.get("message") or {}
|
|
37
|
+
if m.get("role") != "assistant":
|
|
38
|
+
continue
|
|
39
|
+
for b in m.get("content") or []:
|
|
40
|
+
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("name") in ("Write", "Edit", "NotebookEdit"):
|
|
41
|
+
fp = (b.get("input") or {}).get("file_path", "")
|
|
42
|
+
if fp.endswith(".md"):
|
|
43
|
+
n += 1
|
|
44
|
+
print(n)
|
|
45
|
+
PYEOF
|
|
46
|
+
)
|
|
47
|
+
[[ "${EDITED_MD:-0}" -ge 1 ]] || exit 0
|
|
48
|
+
|
|
49
|
+
# Resolve the easymd CLI from this script's own package (no PATH dependency).
|
|
50
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
51
|
+
EASYMD_BIN="$SCRIPT_DIR/../../../bin/easymd.js"
|
|
52
|
+
[[ -f "$EASYMD_BIN" ]] || exit 0
|
|
53
|
+
|
|
54
|
+
cd "${CWD:-$PWD}" 2>/dev/null || exit 0
|
|
55
|
+
node "$EASYMD_BIN" sync . --quiet >/dev/null 2>&1 || true
|
|
56
|
+
exit 0
|
package/src/cli/sync.js
CHANGED
|
@@ -5,6 +5,22 @@ import { requireCredentials } from './config.js';
|
|
|
5
5
|
|
|
6
6
|
const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', 'build', 'coverage']);
|
|
7
7
|
|
|
8
|
+
// "Load-bearing" agent files excluded from auto-sync by default. The account's
|
|
9
|
+
// `sync_special` setting (toggled in the dashboard) opts back in.
|
|
10
|
+
const SPECIAL_MD = new Set(['claude.md', 'readme.md', 'agents.md']);
|
|
11
|
+
const isSpecial = (filePath) => SPECIAL_MD.has(basename(filePath).toLowerCase());
|
|
12
|
+
|
|
13
|
+
// Ask the server whether this account wants the special files included.
|
|
14
|
+
async function syncSpecialEnabled(creds) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(`${creds.url}/api/cli/settings`, { headers: { Authorization: `Bearer ${creds.token}` } });
|
|
17
|
+
if (res.ok) return Boolean((await res.json()).syncSpecial);
|
|
18
|
+
} catch {
|
|
19
|
+
/* default to excluding on error */
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
// Document name sent to the server: repo-relative path without the .md extension.
|
|
9
25
|
// The server slugs it (e.g. "docs/spec" → "docs-spec") and namespaces it to the account.
|
|
10
26
|
function docNameFor(filePath, root) {
|
|
@@ -50,7 +66,9 @@ async function collectMarkdown(dir, root, out = []) {
|
|
|
50
66
|
// One-shot: push every .md under `root` to the account.
|
|
51
67
|
export async function syncDir(root, { quiet = false } = {}) {
|
|
52
68
|
const creds = await requireCredentials();
|
|
53
|
-
const
|
|
69
|
+
const includeSpecial = await syncSpecialEnabled(creds);
|
|
70
|
+
let files = await collectMarkdown(root, root);
|
|
71
|
+
if (!includeSpecial) files = files.filter((f) => !isSpecial(f));
|
|
54
72
|
if (!files.length) {
|
|
55
73
|
if (!quiet) console.log('No .md files found.');
|
|
56
74
|
return { ok: 0, fail: 0 };
|
|
@@ -74,10 +92,12 @@ export async function syncDir(root, { quiet = false } = {}) {
|
|
|
74
92
|
// Long-running: watch `root` and push .md files as they're added/changed.
|
|
75
93
|
export async function watchDir(root, { quiet = false } = {}) {
|
|
76
94
|
const creds = await requireCredentials();
|
|
95
|
+
const includeSpecial = await syncSpecialEnabled(creds);
|
|
77
96
|
const log = (...a) => !quiet && console.log(...a);
|
|
78
97
|
|
|
79
98
|
const pending = new Map(); // path -> timer (debounce)
|
|
80
99
|
const push = (filePath) => {
|
|
100
|
+
if (!includeSpecial && isSpecial(filePath)) return; // skip CLAUDE/README/AGENTS by default
|
|
81
101
|
clearTimeout(pending.get(filePath));
|
|
82
102
|
pending.set(
|
|
83
103
|
filePath,
|