@dujavi/ai-md 0.6.0 → 0.6.1
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/README.md +7 -3
- package/bin/ai-md.js +60 -51
- package/lib/commands.js +133 -4
- package/lib/config.js +40 -11
- package/lib/remote.js +116 -0
- package/lib/status.js +4 -1
- package/package.json +1 -1
- package/scripts/sync-config.sh +10 -3
package/README.md
CHANGED
|
@@ -33,11 +33,15 @@ Cursor / Gemini / OpenCode may *also read* `~/.agents/skills`; enabling `agents`
|
|
|
33
33
|
```bash
|
|
34
34
|
npm i -g @dujavi/ai-md
|
|
35
35
|
|
|
36
|
-
# A)
|
|
36
|
+
# A) Existing private git repo (clone/sync first — never skeleton-before-sync)
|
|
37
|
+
ai-md setup --remote https://github.com/<you>/.ai-md.git
|
|
38
|
+
|
|
39
|
+
# B) No remote yet → local skeleton only
|
|
37
40
|
ai-md init
|
|
38
41
|
|
|
39
|
-
#
|
|
40
|
-
|
|
42
|
+
# C) Optional auto-detect: if `gh` (or git github.user) identifies you
|
|
43
|
+
# AND github.com/<user>/.ai-md exists, init/setup use that remote.
|
|
44
|
+
# There is no hardcoded default remote.
|
|
41
45
|
|
|
42
46
|
ai-md pull
|
|
43
47
|
# edit shared/ or agents/<id>/ only
|
package/bin/ai-md.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
buildAndLink,
|
|
22
22
|
runBuild,
|
|
23
23
|
runRescue,
|
|
24
|
+
bootstrapContent,
|
|
24
25
|
seedSkeleton,
|
|
25
26
|
initRepo,
|
|
26
27
|
harnessList,
|
|
@@ -253,9 +254,9 @@ Unique sync targets: cursor, claude, gemini, opencode, copilot
|
|
|
253
254
|
Shared ~/.agents/skills: agents (canonical); codex is an alias
|
|
254
255
|
|
|
255
256
|
Commands:
|
|
256
|
-
init
|
|
257
|
+
init Bootstrap ~/.ai-md (clone if remote known, else skeleton)
|
|
257
258
|
seed-skeleton Add missing recommended files only
|
|
258
|
-
setup
|
|
259
|
+
setup Clone/sync if remote known; --init for skeleton; --script
|
|
259
260
|
config Show config; config set --remote/--dir/--link-mode
|
|
260
261
|
status Snapshot (default)
|
|
261
262
|
doctor Diagnose; --fix rebuilds + relinks (installed AIs only)
|
|
@@ -371,6 +372,7 @@ function main() {
|
|
|
371
372
|
agents: cfg.agents,
|
|
372
373
|
defaultLinkMode: defaultLinkMode(),
|
|
373
374
|
sources: cfg.sources,
|
|
375
|
+
remoteDetection: cfg.remoteDetection,
|
|
374
376
|
},
|
|
375
377
|
},
|
|
376
378
|
json: opts.json,
|
|
@@ -379,32 +381,30 @@ function main() {
|
|
|
379
381
|
break;
|
|
380
382
|
}
|
|
381
383
|
case "init": {
|
|
382
|
-
const data =
|
|
384
|
+
const data = bootstrapContent(cfg, {
|
|
383
385
|
noGit: opts.noGit,
|
|
384
386
|
force: opts.force,
|
|
385
387
|
dryRun: opts.dryRun,
|
|
388
|
+
forceLink: opts.forceLink,
|
|
389
|
+
agents: agentsOrDefault(opts, cfg),
|
|
386
390
|
});
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
391
|
+
const help =
|
|
392
|
+
data.action === "cloned" ||
|
|
393
|
+
data.action === "synced" ||
|
|
394
|
+
data.action === "would_clone" ||
|
|
395
|
+
data.action === "would_replace_and_clone"
|
|
396
|
+
? [
|
|
397
|
+
`Synced from ${data.remote || cfg.remote}`,
|
|
398
|
+
"Edit shared/ or agents/<id>/; then ai-md build && ai-md push -m \"…\"",
|
|
399
|
+
]
|
|
400
|
+
: [
|
|
401
|
+
"Skeleton created (no remote).",
|
|
402
|
+
"Connect later: ai-md setup --remote https://github.com/<you>/.ai-md.git",
|
|
403
|
+
];
|
|
401
404
|
emit({
|
|
402
|
-
data: { init: data
|
|
405
|
+
data: { init: data },
|
|
403
406
|
json: opts.json,
|
|
404
|
-
help
|
|
405
|
-
"Edit shared/ or agents/<id>/",
|
|
406
|
-
"Later: ai-md config set --remote <url> && git -C ~/.ai-md push -u origin main",
|
|
407
|
-
],
|
|
407
|
+
help,
|
|
408
408
|
});
|
|
409
409
|
break;
|
|
410
410
|
}
|
|
@@ -517,54 +517,63 @@ function main() {
|
|
|
517
517
|
break;
|
|
518
518
|
}
|
|
519
519
|
case "setup": {
|
|
520
|
-
|
|
521
|
-
|
|
520
|
+
const remoteCfg = resolveConfig(process.env, {
|
|
521
|
+
dir: opts.dir || undefined,
|
|
522
|
+
remote: opts.remote || undefined,
|
|
523
|
+
});
|
|
524
|
+
const remote = opts.remote || remoteCfg.remote || null;
|
|
525
|
+
|
|
526
|
+
// Explicit skeleton path only when no remote is known
|
|
527
|
+
if (opts.init && !remote) {
|
|
528
|
+
const data = bootstrapContent(remoteCfg, {
|
|
522
529
|
noGit: opts.noGit,
|
|
523
530
|
force: opts.force,
|
|
524
531
|
dryRun: opts.dryRun,
|
|
532
|
+
forceLink: opts.forceLink,
|
|
533
|
+
agents: agentsOrDefault(opts, remoteCfg),
|
|
525
534
|
});
|
|
526
|
-
writeMachineConfig(
|
|
527
|
-
{ dir: cfg.dir, agents: ["cursor"], linkMode: cfg.linkMode },
|
|
528
|
-
process.env,
|
|
529
|
-
{ dryRun: opts.dryRun }
|
|
530
|
-
);
|
|
531
|
-
cfg = resolveConfig(process.env);
|
|
532
|
-
const bl = opts.dryRun
|
|
533
|
-
? null
|
|
534
|
-
: buildAndLink(cfg, {
|
|
535
|
-
agents: agentsOrDefault(opts, cfg),
|
|
536
|
-
force: true,
|
|
537
|
-
forceLink: opts.forceLink,
|
|
538
|
-
});
|
|
539
535
|
emit({
|
|
540
|
-
data: { setup: "init",
|
|
536
|
+
data: { setup: "init", ...data },
|
|
541
537
|
json: opts.json,
|
|
542
538
|
help: [],
|
|
543
539
|
});
|
|
544
540
|
break;
|
|
545
541
|
}
|
|
542
|
+
|
|
543
|
+
if (!remote) {
|
|
544
|
+
fail(
|
|
545
|
+
"setup needs a remote (or --init for local skeleton).\n" +
|
|
546
|
+
" ai-md setup --remote https://github.com/<you>/.ai-md.git\n" +
|
|
547
|
+
" ai-md init\n" +
|
|
548
|
+
" (auto-detect: authenticate `gh` and create github.com/<user>/.ai-md)",
|
|
549
|
+
{ exitCode: 2, json: opts.json }
|
|
550
|
+
);
|
|
551
|
+
process.exit(2);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Remote known → clone/sync first; never seed skeleton before sync
|
|
546
555
|
const saved = writeMachineConfig(
|
|
547
556
|
{
|
|
548
|
-
dir: opts.dir ||
|
|
549
|
-
remote
|
|
550
|
-
agents: opts.agents ||
|
|
551
|
-
linkMode: opts.linkMode ||
|
|
557
|
+
dir: opts.dir || remoteCfg.dir,
|
|
558
|
+
remote,
|
|
559
|
+
agents: opts.agents || remoteCfg.agents,
|
|
560
|
+
linkMode: opts.linkMode || remoteCfg.linkMode,
|
|
552
561
|
},
|
|
553
562
|
process.env,
|
|
554
563
|
{ dryRun: opts.dryRun }
|
|
555
564
|
);
|
|
556
565
|
cfg = resolveConfig(process.env, {
|
|
557
566
|
dir: opts.dir || undefined,
|
|
558
|
-
remote
|
|
567
|
+
remote,
|
|
568
|
+
skipRemoteDetect: true,
|
|
559
569
|
});
|
|
560
570
|
applyEnvFromConfig(cfg);
|
|
561
|
-
const result =
|
|
562
|
-
|
|
563
|
-
:
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
});
|
|
571
|
+
const result = bootstrapContent(cfg, {
|
|
572
|
+
force: opts.force,
|
|
573
|
+
dryRun: opts.dryRun,
|
|
574
|
+
forceLink: opts.forceLink,
|
|
575
|
+
agents: agentsOrDefault(opts, cfg),
|
|
576
|
+
});
|
|
568
577
|
const scripts =
|
|
569
578
|
opts.scripts.length === 0
|
|
570
579
|
? []
|
|
@@ -578,7 +587,7 @@ function main() {
|
|
|
578
587
|
from: opts.from,
|
|
579
588
|
});
|
|
580
589
|
emit({
|
|
581
|
-
data: { setup: "ok", config: saved,
|
|
590
|
+
data: { setup: "ok", config: saved, result, scripts, ...data },
|
|
582
591
|
json: opts.json,
|
|
583
592
|
help: statusHelp(data),
|
|
584
593
|
});
|
package/lib/commands.js
CHANGED
|
@@ -22,7 +22,7 @@ const {
|
|
|
22
22
|
ensureAgentSourceDirs,
|
|
23
23
|
isHarnessInstalled,
|
|
24
24
|
} = require("./harnesses");
|
|
25
|
-
const { seedSkeleton, initRepo, SKELETON_VERSION } = require("./skeleton");
|
|
25
|
+
const { seedSkeleton, initRepo, SKELETON_VERSION, isEmptyDir } = require("./skeleton");
|
|
26
26
|
|
|
27
27
|
function ensureGitignore(repoPath, { dryRun = false } = {}) {
|
|
28
28
|
const gitignore = path.join(repoPath, ".gitignore");
|
|
@@ -259,16 +259,39 @@ function buildAndLink(cfg, opts = {}) {
|
|
|
259
259
|
|
|
260
260
|
function runInstall(cfg, opts = {}) {
|
|
261
261
|
const dir = cfg.dir;
|
|
262
|
-
|
|
262
|
+
const empty = !fs.existsSync(dir) || isEmptyDir(dir);
|
|
263
|
+
|
|
264
|
+
if (empty) {
|
|
265
|
+
if (!cfg.remote) {
|
|
266
|
+
const err = new Error(
|
|
267
|
+
"No remote configured and content dir is empty.\n" +
|
|
268
|
+
" ai-md init # skeleton only (no remote)\n" +
|
|
269
|
+
" ai-md setup --remote <url> # clone existing github.com/<user>/.ai-md"
|
|
270
|
+
);
|
|
271
|
+
err.code = "EINVAL";
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
if (fs.existsSync(dir) && isEmptyDir(dir)) {
|
|
275
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
276
|
+
}
|
|
263
277
|
gitClone(cfg.remote, dir, { dryRun: opts.dryRun });
|
|
278
|
+
if (!opts.dryRun) {
|
|
279
|
+
seedSkeleton(cfg, { force: false });
|
|
280
|
+
}
|
|
264
281
|
} else if (!fs.existsSync(path.join(dir, ".git"))) {
|
|
265
282
|
const err = new Error(
|
|
266
|
-
`${dir} exists but is not a git repo.
|
|
283
|
+
`${dir} exists but is not a git repo. Move it aside, then: ai-md setup --remote <url>`
|
|
267
284
|
);
|
|
268
285
|
err.code = "EEXIST";
|
|
269
286
|
throw err;
|
|
287
|
+
} else if (cfg.remote && opts.pull !== false) {
|
|
288
|
+
try {
|
|
289
|
+
gitPull(cfg.dir, { dryRun: opts.dryRun });
|
|
290
|
+
} catch {
|
|
291
|
+
/* offline / no upstream — continue with local tree */
|
|
292
|
+
}
|
|
270
293
|
}
|
|
271
|
-
|
|
294
|
+
|
|
272
295
|
return buildAndLink(cfg, {
|
|
273
296
|
agents: opts.agents,
|
|
274
297
|
force: opts.force,
|
|
@@ -277,6 +300,111 @@ function runInstall(cfg, opts = {}) {
|
|
|
277
300
|
});
|
|
278
301
|
}
|
|
279
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Bootstrap: remote set → clone/sync first (never skeleton-first).
|
|
305
|
+
* No remote → seed skeleton only.
|
|
306
|
+
*/
|
|
307
|
+
function bootstrapContent(cfg, opts = {}) {
|
|
308
|
+
const {
|
|
309
|
+
noGit = false,
|
|
310
|
+
force = false,
|
|
311
|
+
dryRun = false,
|
|
312
|
+
forceLink = false,
|
|
313
|
+
agents = ["cursor"],
|
|
314
|
+
} = opts;
|
|
315
|
+
|
|
316
|
+
if (cfg.remote) {
|
|
317
|
+
const dir = cfg.dir;
|
|
318
|
+
const exists = fs.existsSync(dir);
|
|
319
|
+
const empty = !exists || isEmptyDir(dir);
|
|
320
|
+
const isGit = exists && fs.existsSync(path.join(dir, ".git"));
|
|
321
|
+
|
|
322
|
+
if (!empty && isGit) {
|
|
323
|
+
if (!dryRun) {
|
|
324
|
+
writeMachineConfig({
|
|
325
|
+
dir: cfg.dir,
|
|
326
|
+
remote: cfg.remote,
|
|
327
|
+
agents,
|
|
328
|
+
linkMode: cfg.linkMode,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
action: "synced",
|
|
333
|
+
remote: cfg.remote,
|
|
334
|
+
dir,
|
|
335
|
+
...runInstall(cfg, {
|
|
336
|
+
agents,
|
|
337
|
+
force,
|
|
338
|
+
dryRun,
|
|
339
|
+
forceLink,
|
|
340
|
+
pull: true,
|
|
341
|
+
}),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!empty && !isGit) {
|
|
346
|
+
if (!force) {
|
|
347
|
+
const err = new Error(
|
|
348
|
+
`${dir} is not empty and has no .git.\n` +
|
|
349
|
+
` mv ${dir} ${dir}.bak && ai-md setup --remote ${cfg.remote}\n` +
|
|
350
|
+
` (refusing to seed skeleton before sync when a remote is set)`
|
|
351
|
+
);
|
|
352
|
+
err.code = "EEXIST";
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
if (dryRun) {
|
|
356
|
+
return { action: "would_replace_and_clone", remote: cfg.remote, dir };
|
|
357
|
+
}
|
|
358
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (dryRun) {
|
|
362
|
+
return { action: "would_clone", remote: cfg.remote, dir };
|
|
363
|
+
}
|
|
364
|
+
if (fs.existsSync(dir) && isEmptyDir(dir)) {
|
|
365
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
gitClone(cfg.remote, dir, { dryRun: false });
|
|
368
|
+
// After clone only — fill gaps; never invent a tree instead of syncing
|
|
369
|
+
seedSkeleton(cfg, { force: false });
|
|
370
|
+
writeMachineConfig({
|
|
371
|
+
dir: cfg.dir,
|
|
372
|
+
remote: cfg.remote,
|
|
373
|
+
agents,
|
|
374
|
+
linkMode: cfg.linkMode,
|
|
375
|
+
});
|
|
376
|
+
const fresh = resolveConfig(process.env, {
|
|
377
|
+
dir: cfg.dir,
|
|
378
|
+
remote: cfg.remote,
|
|
379
|
+
skipRemoteDetect: true,
|
|
380
|
+
});
|
|
381
|
+
return {
|
|
382
|
+
action: "cloned",
|
|
383
|
+
remote: cfg.remote,
|
|
384
|
+
dir,
|
|
385
|
+
...buildAndLink(fresh, { agents, force: true, forceLink }),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const data = initRepo(cfg, { noGit, force, dryRun });
|
|
390
|
+
if (!dryRun) {
|
|
391
|
+
writeMachineConfig({
|
|
392
|
+
dir: cfg.dir,
|
|
393
|
+
agents,
|
|
394
|
+
linkMode: cfg.linkMode,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
const fresh = resolveConfig(process.env, {
|
|
398
|
+
dir: cfg.dir,
|
|
399
|
+
skipRemoteDetect: true,
|
|
400
|
+
});
|
|
401
|
+
let bl = null;
|
|
402
|
+
if (!dryRun) {
|
|
403
|
+
bl = buildAndLink(fresh, { agents, force: true, forceLink });
|
|
404
|
+
}
|
|
405
|
+
return { action: "initialized", init: data, buildLink: bl };
|
|
406
|
+
}
|
|
407
|
+
|
|
280
408
|
function runPull(cfg, opts = {}) {
|
|
281
409
|
if (!fs.existsSync(path.join(cfg.dir, ".git"))) {
|
|
282
410
|
const err = new Error(`${cfg.dir} is not a git repo; run ai-md install`);
|
|
@@ -443,6 +571,7 @@ module.exports = {
|
|
|
443
571
|
runRescue,
|
|
444
572
|
seedSkeleton,
|
|
445
573
|
initRepo,
|
|
574
|
+
bootstrapContent,
|
|
446
575
|
harnessList,
|
|
447
576
|
harnessShow,
|
|
448
577
|
harnessSet,
|
package/lib/config.js
CHANGED
|
@@ -4,8 +4,10 @@ const fs = require("fs");
|
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { execFileSync } = require("child_process");
|
|
6
6
|
const { expandHome } = require("./config-paths");
|
|
7
|
+
const { detectDefaultRemote } = require("./remote");
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
/** @deprecated No hardcoded personal default — use detectDefaultRemote(). */
|
|
10
|
+
const DEFAULT_REMOTE = null;
|
|
9
11
|
|
|
10
12
|
function machineConfigPath(env = process.env) {
|
|
11
13
|
if (env.AI_MD_CONFIG) return expandHome(env.AI_MD_CONFIG);
|
|
@@ -67,7 +69,24 @@ function resolveConfig(env = process.env, opts = {}) {
|
|
|
67
69
|
const envRemote = env.AI_MD_REMOTE || env.CURSOR_MD_REMOTE || null;
|
|
68
70
|
|
|
69
71
|
const dir = expandHome(opts.dir || envDir || stored.dir || defaultDir);
|
|
70
|
-
|
|
72
|
+
|
|
73
|
+
let remote = opts.remote || envRemote || stored.remote || null;
|
|
74
|
+
let remoteSource = opts.remote
|
|
75
|
+
? "flag"
|
|
76
|
+
: envRemote
|
|
77
|
+
? "env"
|
|
78
|
+
: stored.remote
|
|
79
|
+
? "config"
|
|
80
|
+
: "none";
|
|
81
|
+
let remoteDetection = null;
|
|
82
|
+
|
|
83
|
+
if (!remote && opts.skipRemoteDetect !== true) {
|
|
84
|
+
remoteDetection = detectDefaultRemote();
|
|
85
|
+
if (remoteDetection.remote) {
|
|
86
|
+
remote = remoteDetection.remote;
|
|
87
|
+
remoteSource = "detected";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
71
90
|
|
|
72
91
|
const sources = {
|
|
73
92
|
dir: opts.dir
|
|
@@ -77,13 +96,7 @@ function resolveConfig(env = process.env, opts = {}) {
|
|
|
77
96
|
: stored.dir
|
|
78
97
|
? "config"
|
|
79
98
|
: "default",
|
|
80
|
-
remote:
|
|
81
|
-
? "flag"
|
|
82
|
-
: envRemote && !(stored.remote && envRemote === stored.remote)
|
|
83
|
-
? "env"
|
|
84
|
-
: stored.remote
|
|
85
|
-
? "config"
|
|
86
|
-
: "default",
|
|
99
|
+
remote: remoteSource,
|
|
87
100
|
};
|
|
88
101
|
|
|
89
102
|
const raw = stored.raw || {};
|
|
@@ -98,6 +111,7 @@ function resolveConfig(env = process.env, opts = {}) {
|
|
|
98
111
|
dir,
|
|
99
112
|
remote,
|
|
100
113
|
sources,
|
|
114
|
+
remoteDetection,
|
|
101
115
|
agents,
|
|
102
116
|
linkMode,
|
|
103
117
|
machineConfigPath: stored.path,
|
|
@@ -118,9 +132,17 @@ function resolveConfig(env = process.env, opts = {}) {
|
|
|
118
132
|
|
|
119
133
|
function applyEnvFromConfig(cfg) {
|
|
120
134
|
process.env.AI_MD_DIR = cfg.dir;
|
|
121
|
-
process.env.AI_MD_REMOTE = cfg.remote;
|
|
122
135
|
process.env.CURSOR_MD_DIR = cfg.dir;
|
|
123
|
-
|
|
136
|
+
// Never invent remotes into the environment (avoids fake defaults and
|
|
137
|
+
// flipping sources.remote to "env" on re-resolve). Only pass through when
|
|
138
|
+
// the process already had one, or the caller passed --remote (flag).
|
|
139
|
+
if (
|
|
140
|
+
cfg.remote &&
|
|
141
|
+
(cfg.sources.remote === "env" || cfg.sources.remote === "flag")
|
|
142
|
+
) {
|
|
143
|
+
process.env.AI_MD_REMOTE = cfg.remote;
|
|
144
|
+
process.env.CURSOR_MD_REMOTE = cfg.remote;
|
|
145
|
+
}
|
|
124
146
|
}
|
|
125
147
|
|
|
126
148
|
function templatePath(cfg, name = "base") {
|
|
@@ -201,6 +223,13 @@ function migrateLegacyTemplate(cfg, { dryRun = false } = {}) {
|
|
|
201
223
|
}
|
|
202
224
|
|
|
203
225
|
function gitClone(remote, dir, { dryRun = false } = {}) {
|
|
226
|
+
if (!remote) {
|
|
227
|
+
const err = new Error(
|
|
228
|
+
"git clone requires a remote URL (pass --remote or set AI_MD_REMOTE)"
|
|
229
|
+
);
|
|
230
|
+
err.code = "EINVAL";
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
204
233
|
if (dryRun) return { action: "would_clone", remote, dir };
|
|
205
234
|
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
206
235
|
execFileSync("git", ["clone", remote, dir], { stdio: "inherit" });
|
package/lib/remote.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require("child_process");
|
|
4
|
+
|
|
5
|
+
function runCapture(cmd, args, opts = {}) {
|
|
6
|
+
try {
|
|
7
|
+
return execFileSync(cmd, args, {
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
10
|
+
timeout: opts.timeout || 20000,
|
|
11
|
+
}).trim();
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* GitHub login from authenticated `gh`, else git config github.user.
|
|
19
|
+
*/
|
|
20
|
+
function detectGithubUser() {
|
|
21
|
+
const fromGh = runCapture("gh", ["api", "user", "-q", ".login"]);
|
|
22
|
+
if (fromGh && !fromGh.includes(" ") && /^[A-Za-z0-9-]+$/.test(fromGh)) {
|
|
23
|
+
return { user: fromGh, via: "gh" };
|
|
24
|
+
}
|
|
25
|
+
const fromGit = runCapture("git", ["config", "--global", "github.user"]);
|
|
26
|
+
if (fromGit && /^[A-Za-z0-9-]+$/.test(fromGit)) {
|
|
27
|
+
return { user: fromGit, via: "git" };
|
|
28
|
+
}
|
|
29
|
+
return { user: null, via: null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function candidateRemotes(user) {
|
|
33
|
+
return [
|
|
34
|
+
`https://github.com/${user}/.ai-md.git`,
|
|
35
|
+
`git@github.com:${user}/.ai-md.git`,
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function remoteReachable(url) {
|
|
40
|
+
const out = runCapture("git", ["ls-remote", "--exit-code", url, "HEAD"], {
|
|
41
|
+
timeout: 25000,
|
|
42
|
+
});
|
|
43
|
+
// ls-remote --exit-code returns null from runCapture on failure
|
|
44
|
+
// success returns stdout (may be empty-ish but non-null string with sha)
|
|
45
|
+
return out != null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function repoExistsViaGh(user) {
|
|
49
|
+
const url = runCapture("gh", [
|
|
50
|
+
"repo",
|
|
51
|
+
"view",
|
|
52
|
+
`${user}/.ai-md`,
|
|
53
|
+
"--json",
|
|
54
|
+
"url",
|
|
55
|
+
"-q",
|
|
56
|
+
".url",
|
|
57
|
+
]);
|
|
58
|
+
return Boolean(url);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Only invent a default remote when gh/git identifies a user AND
|
|
63
|
+
* github.com/{user}/.ai-md exists.
|
|
64
|
+
*/
|
|
65
|
+
function detectDefaultRemote() {
|
|
66
|
+
const { user, via } = detectGithubUser();
|
|
67
|
+
if (!user) {
|
|
68
|
+
return {
|
|
69
|
+
remote: null,
|
|
70
|
+
source: "none",
|
|
71
|
+
githubUser: null,
|
|
72
|
+
githubVia: null,
|
|
73
|
+
verified: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (repoExistsViaGh(user)) {
|
|
78
|
+
return {
|
|
79
|
+
remote: `https://github.com/${user}/.ai-md.git`,
|
|
80
|
+
source: "detected",
|
|
81
|
+
githubUser: user,
|
|
82
|
+
githubVia: via,
|
|
83
|
+
verified: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const url of candidateRemotes(user)) {
|
|
88
|
+
if (remoteReachable(url)) {
|
|
89
|
+
return {
|
|
90
|
+
remote: url.startsWith("git@")
|
|
91
|
+
? `https://github.com/${user}/.ai-md.git`
|
|
92
|
+
: url,
|
|
93
|
+
source: "detected",
|
|
94
|
+
githubUser: user,
|
|
95
|
+
githubVia: via,
|
|
96
|
+
verified: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
remote: null,
|
|
103
|
+
source: "none",
|
|
104
|
+
githubUser: user,
|
|
105
|
+
githubVia: via,
|
|
106
|
+
verified: false,
|
|
107
|
+
hint: `No github.com/${user}/.ai-md repo found (create it or pass --remote)`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
detectGithubUser,
|
|
113
|
+
detectDefaultRemote,
|
|
114
|
+
remoteReachable,
|
|
115
|
+
candidateRemotes,
|
|
116
|
+
};
|
package/lib/status.js
CHANGED
|
@@ -184,8 +184,11 @@ function collectStatus(opts = {}) {
|
|
|
184
184
|
return {
|
|
185
185
|
generatedAt: new Date().toISOString(),
|
|
186
186
|
dir: cfg.dir,
|
|
187
|
-
remote: remote || cfg.remote,
|
|
187
|
+
remote: remote || cfg.remote || null,
|
|
188
|
+
configuredRemote: cfg.remote || null,
|
|
189
|
+
gitRemote: remote,
|
|
188
190
|
sources: cfg.sources,
|
|
191
|
+
remoteDetection: cfg.remoteDetection,
|
|
189
192
|
linkMode: cfg.linkMode,
|
|
190
193
|
wsl: isWsl(),
|
|
191
194
|
machineConfigPath: cfg.machineConfigPath,
|
package/package.json
CHANGED
package/scripts/sync-config.sh
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
set -euo pipefail
|
|
5
5
|
|
|
6
6
|
REPO="${AI_MD_DIR:-${CURSOR_MD_DIR:-$HOME/.ai-md}}"
|
|
7
|
-
REMOTE_URL="${AI_MD_REMOTE:-${CURSOR_MD_REMOTE:-
|
|
7
|
+
REMOTE_URL="${AI_MD_REMOTE:-${CURSOR_MD_REMOTE:-}}"
|
|
8
8
|
DRY_RUN=0
|
|
9
9
|
FORCE=0
|
|
10
10
|
COMMIT_MSG="Update personal AI skills/rules"
|
|
@@ -32,10 +32,10 @@ Options:
|
|
|
32
32
|
|
|
33
33
|
Environment:
|
|
34
34
|
AI_MD_DIR Private config path (default: ~/.ai-md)
|
|
35
|
-
AI_MD_REMOTE Clone URL if repo missing (default
|
|
35
|
+
AI_MD_REMOTE Clone URL if repo missing (required for install clone; no hardcoded default)
|
|
36
36
|
|
|
37
37
|
Examples:
|
|
38
|
-
ai-md install
|
|
38
|
+
AI_MD_REMOTE=https://github.com/<you>/.ai-md.git ai-md install
|
|
39
39
|
ai-md pull
|
|
40
40
|
ai-md push -m "Add grafana rule"
|
|
41
41
|
ai-md status
|
|
@@ -82,6 +82,13 @@ clone_if_missing() {
|
|
|
82
82
|
fi
|
|
83
83
|
if [[ -d "$REPO" ]] && [[ -n "$(ls -A "$REPO" 2>/dev/null || true)" ]]; then
|
|
84
84
|
err "$REPO exists but is not a git repo (and is not empty)"
|
|
85
|
+
err " Move it aside, then clone — do not seed a skeleton over an unsynced remote tree"
|
|
86
|
+
exit 1
|
|
87
|
+
fi
|
|
88
|
+
if [[ -z "$REMOTE_URL" ]]; then
|
|
89
|
+
err "No remote set and $REPO is missing/empty."
|
|
90
|
+
err " export AI_MD_REMOTE=https://github.com/<you>/.ai-md.git"
|
|
91
|
+
err " or: ai-md setup --remote <url> / ai-md init"
|
|
85
92
|
exit 1
|
|
86
93
|
fi
|
|
87
94
|
log "Cloning $REMOTE_URL → $REPO"
|