threadit-cli 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.
- package/assets/install.sh +16 -0
- package/assets/pack/capture-directive.md +1 -0
- package/assets/skills/threadit-orient/SKILL.md +15 -0
- package/assets/skills/threadit-reconcile/SKILL.md +15 -0
- package/dist/assets.d.ts +3 -0
- package/dist/assets.js +16 -0
- package/dist/bin.d.ts +11 -0
- package/dist/bin.js +472 -0
- package/dist/commands/accept.d.ts +12 -0
- package/dist/commands/accept.js +21 -0
- package/dist/commands/add.d.ts +11 -0
- package/dist/commands/add.js +19 -0
- package/dist/commands/capture.d.ts +24 -0
- package/dist/commands/capture.js +45 -0
- package/dist/commands/doc.d.ts +7 -0
- package/dist/commands/doc.js +56 -0
- package/dist/commands/drop.d.ts +4 -0
- package/dist/commands/drop.js +12 -0
- package/dist/commands/fold.d.ts +1 -0
- package/dist/commands/fold.js +9 -0
- package/dist/commands/hook.d.ts +24 -0
- package/dist/commands/hook.js +52 -0
- package/dist/commands/init.d.ts +18 -0
- package/dist/commands/init.js +55 -0
- package/dist/commands/ls.d.ts +8 -0
- package/dist/commands/ls.js +27 -0
- package/dist/commands/merge.d.ts +4 -0
- package/dist/commands/merge.js +18 -0
- package/dist/commands/move.d.ts +11 -0
- package/dist/commands/move.js +45 -0
- package/dist/commands/pack.d.ts +5 -0
- package/dist/commands/pack.js +12 -0
- package/dist/commands/park.d.ts +2 -0
- package/dist/commands/park.js +10 -0
- package/dist/commands/promote.d.ts +11 -0
- package/dist/commands/promote.js +33 -0
- package/dist/commands/reconcile.d.ts +12 -0
- package/dist/commands/reconcile.js +97 -0
- package/dist/commands/serve.d.ts +17 -0
- package/dist/commands/serve.js +39 -0
- package/dist/commands/ship.d.ts +9 -0
- package/dist/commands/ship.js +28 -0
- package/dist/commands/show.d.ts +2 -0
- package/dist/commands/show.js +16 -0
- package/dist/commands/snooze.d.ts +5 -0
- package/dist/commands/snooze.js +16 -0
- package/dist/commands/status.d.ts +14 -0
- package/dist/commands/status.js +47 -0
- package/dist/commands/supersede.d.ts +1 -0
- package/dist/commands/supersede.js +9 -0
- package/dist/commands/sync.d.ts +16 -0
- package/dist/commands/sync.js +66 -0
- package/dist/commands/update.d.ts +13 -0
- package/dist/commands/update.js +23 -0
- package/dist/commands/validate.d.ts +18 -0
- package/dist/commands/validate.js +52 -0
- package/dist/commands/wrapup.d.ts +3 -0
- package/dist/commands/wrapup.js +10 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +20 -0
- package/dist/draft.d.ts +29 -0
- package/dist/draft.js +59 -0
- package/dist/git/commitFacts.d.ts +6 -0
- package/dist/git/commitFacts.js +56 -0
- package/dist/git/log.d.ts +41 -0
- package/dist/git/log.js +143 -0
- package/dist/git/reconcileCommits.d.ts +3 -0
- package/dist/git/reconcileCommits.js +18 -0
- package/dist/git/run.d.ts +2 -0
- package/dist/git/run.js +8 -0
- package/dist/ids.d.ts +4 -0
- package/dist/ids.js +25 -0
- package/dist/inbox/recurrence.d.ts +7 -0
- package/dist/inbox/recurrence.js +51 -0
- package/dist/install/atomicWrite.d.ts +2 -0
- package/dist/install/atomicWrite.js +9 -0
- package/dist/install/gitHooks.d.ts +6 -0
- package/dist/install/gitHooks.js +53 -0
- package/dist/install/gitignore.d.ts +4 -0
- package/dist/install/gitignore.js +29 -0
- package/dist/install/settings.d.ts +7 -0
- package/dist/install/settings.js +40 -0
- package/dist/install/skills.d.ts +11 -0
- package/dist/install/skills.js +38 -0
- package/dist/install/tty.d.ts +6 -0
- package/dist/install/tty.js +39 -0
- package/dist/mutate.d.ts +6 -0
- package/dist/mutate.js +21 -0
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +46 -0
- package/dist/reconcileFormat.d.ts +3 -0
- package/dist/reconcileFormat.js +30 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +11 -0
- package/dist/transport/sync.d.ts +38 -0
- package/dist/transport/sync.js +64 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +34 -0
- package/dist/yaml/emit.d.ts +3 -0
- package/dist/yaml/emit.js +50 -0
- package/dist/yaml/io.d.ts +11 -0
- package/dist/yaml/io.js +33 -0
- package/package.json +32 -0
package/dist/git/log.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { runGit } from './run.js';
|
|
2
|
+
const US = '\x1f'; // unit separator — safe field delimiter inside one log line
|
|
3
|
+
/**
|
|
4
|
+
* True when git ran and exited non-zero (it set a numeric exit status). A spawn
|
|
5
|
+
* failure — e.g. git not on PATH (ENOENT) — has no numeric status, so it is NOT
|
|
6
|
+
* swallowed; it rethrows. NOTE: an invalid repoDir also exits non-zero and yields
|
|
7
|
+
* the empty/absent result; callers needing a guaranteed repo should validate first.
|
|
8
|
+
*/
|
|
9
|
+
function gitProcessExited(err) {
|
|
10
|
+
return typeof err.status === 'number';
|
|
11
|
+
}
|
|
12
|
+
/** main's first-parent chain, newest-first. `sinceSha` (exclusive) bounds the bottom. */
|
|
13
|
+
export function firstParentChain(repoDir, sinceSha) {
|
|
14
|
+
const range = sinceSha ? `${sinceSha}..HEAD` : 'HEAD';
|
|
15
|
+
let out;
|
|
16
|
+
try {
|
|
17
|
+
out = runGit(repoDir, ['log', '--first-parent', `--format=%H${US}%aI${US}%P`, range]);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (gitProcessExited(err))
|
|
21
|
+
return []; // no commits yet
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
return out.split('\n').filter(Boolean).flatMap((line) => {
|
|
25
|
+
const [sha, authorDate, parents] = line.split(US);
|
|
26
|
+
if (sha === undefined || authorDate === undefined)
|
|
27
|
+
return []; // defensive: malformed line
|
|
28
|
+
const first = (parents ?? '').trim().split(/\s+/).filter(Boolean)[0];
|
|
29
|
+
return [{ sha, authorDate, parent: first ?? null }];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** File content at a revision, or null if the file did not exist there. */
|
|
33
|
+
export function fileBlobAt(repoDir, sha, relPath) {
|
|
34
|
+
try {
|
|
35
|
+
return runGit(repoDir, ['show', `${sha}:${relPath}`]);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (gitProcessExited(err))
|
|
39
|
+
return null;
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* All commits on main's first-parent chain that carry ≥1 `Threadit: <id>` trailer.
|
|
45
|
+
* Uses git's trailer formatting (exact values, no regex fragility).
|
|
46
|
+
*/
|
|
47
|
+
export function trailerCommits(repoDir, sinceSha) {
|
|
48
|
+
const range = sinceSha ? `${sinceSha}..HEAD` : 'HEAD';
|
|
49
|
+
let out;
|
|
50
|
+
try {
|
|
51
|
+
out = runGit(repoDir, [
|
|
52
|
+
'log', '--first-parent',
|
|
53
|
+
`--format=%H${US}%aI${US}%(trailers:key=Threadit,valueonly,separator=%x1f)`,
|
|
54
|
+
range,
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (gitProcessExited(err))
|
|
59
|
+
return [];
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
const result = [];
|
|
63
|
+
for (const line of out.split('\n').filter(Boolean)) {
|
|
64
|
+
const [sha, authorDate, ...rest] = line.split(US);
|
|
65
|
+
if (sha === undefined || authorDate === undefined)
|
|
66
|
+
continue; // defensive: malformed line
|
|
67
|
+
const trailers = rest.filter(Boolean);
|
|
68
|
+
if (trailers.length)
|
|
69
|
+
result.push({ sha, authorDate, trailers });
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The `Threadit: <id>` trailer values on ONE commit (by sha). Mirrors trailerCommits' git-trailer
|
|
75
|
+
* formatter (no regex). Returns [] when the commit carries none.
|
|
76
|
+
*/
|
|
77
|
+
export function trailersFor(repoDir, sha) {
|
|
78
|
+
let out;
|
|
79
|
+
try {
|
|
80
|
+
out = runGit(repoDir, [
|
|
81
|
+
'log', '-1',
|
|
82
|
+
`--format=%(trailers:key=Threadit,valueonly,separator=%x1f)`,
|
|
83
|
+
sha,
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (gitProcessExited(err))
|
|
88
|
+
return [];
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
return out.split('\n').join(US).split(US).map((s) => s.trim()).filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* True iff `maybeAncestor` is an ancestor of `descendant` on the current history. Uses
|
|
95
|
+
* `git merge-base --is-ancestor` (exit 0 = ancestor, exit 1 = NOT ancestor). `descendant` is always
|
|
96
|
+
* a valid ref here ('HEAD'), so a 128 means `maybeAncestor` itself is unresolvable — the stored sha
|
|
97
|
+
* was rewritten away AND pruned/gc'd (or this is a fresh clone). That is itself a divergence signal,
|
|
98
|
+
* so we treat 1 AND 128 as "not an ancestor" rather than crashing sync (the resulting full re-sync is
|
|
99
|
+
* idempotent — safe to over-trigger). A spawn failure (no numeric status) or any other code rethrows,
|
|
100
|
+
* mirroring the gitProcessExited discipline (distinguish a clean answer from a real error).
|
|
101
|
+
*
|
|
102
|
+
* Used by `sync` to detect a history REWRITE: if the server's last-synced sha is NO LONGER an
|
|
103
|
+
* ancestor of HEAD, the chain was rebased/amended/force-pushed and the server must purge + re-ingest.
|
|
104
|
+
*/
|
|
105
|
+
export function isAncestor(repoDir, maybeAncestor, descendant) {
|
|
106
|
+
try {
|
|
107
|
+
runGit(repoDir, ['merge-base', '--is-ancestor', maybeAncestor, descendant]);
|
|
108
|
+
return true; // exit 0 = ancestor
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
const status = err.status;
|
|
112
|
+
// exit 1 = clean "not an ancestor"; exit 128 = `maybeAncestor` is no longer a valid commit
|
|
113
|
+
// (rewritten away + pruned, or a fresh clone) — also a rewrite/divergence signal. Both → false.
|
|
114
|
+
if (status === 1 || status === 128)
|
|
115
|
+
return false;
|
|
116
|
+
throw err; // spawn failure / unexpected code → real error
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** sha → subject line for main's first-parent chain. `sinceSha` (exclusive) bounds the bottom. */
|
|
120
|
+
export function firstParentSubjects(repoDir, sinceSha) {
|
|
121
|
+
const range = sinceSha ? `${sinceSha}..HEAD` : 'HEAD';
|
|
122
|
+
let out;
|
|
123
|
+
try {
|
|
124
|
+
out = runGit(repoDir, ['log', '--first-parent', `--format=%H${US}%s`, range]);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (gitProcessExited(err))
|
|
128
|
+
return new Map();
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
const m = new Map();
|
|
132
|
+
for (const line of out.split('\n').filter(Boolean)) {
|
|
133
|
+
const [sha, subject] = line.split(US);
|
|
134
|
+
if (sha !== undefined)
|
|
135
|
+
m.set(sha, subject ?? '');
|
|
136
|
+
}
|
|
137
|
+
return m;
|
|
138
|
+
}
|
|
139
|
+
/** Paths a single commit touched (root commit handled via --root; -z = NUL-terminated, quoting-safe). */
|
|
140
|
+
export function touchedPaths(repoDir, sha) {
|
|
141
|
+
const out = runGit(repoDir, ['diff-tree', '--no-commit-id', '--name-only', '-r', '--root', '-z', sha]);
|
|
142
|
+
return out.split('\0').filter(Boolean);
|
|
143
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ParsedThreaditFile, ReconcileCommit } from '@threadit/core';
|
|
2
|
+
/** One ReconcileCommit per first-parent commit since `sinceSha` (exclusive). */
|
|
3
|
+
export declare function gatherReconcileCommits(repoDir: string, file: ParsedThreaditFile, sinceSha?: string): ReconcileCommit[];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { firstParentChain, firstParentSubjects, trailerCommits, touchedPaths } from './log.js';
|
|
2
|
+
import { isOwnedPath } from '../paths.js';
|
|
3
|
+
/** One ReconcileCommit per first-parent commit since `sinceSha` (exclusive). */
|
|
4
|
+
export function gatherReconcileCommits(repoDir, file, sinceSha) {
|
|
5
|
+
const chain = firstParentChain(repoDir, sinceSha);
|
|
6
|
+
const subjects = firstParentSubjects(repoDir, sinceSha);
|
|
7
|
+
const trailered = new Set(trailerCommits(repoDir, sinceSha).map((tc) => tc.sha));
|
|
8
|
+
return chain.map((c) => {
|
|
9
|
+
const paths = touchedPaths(repoDir, c.sha);
|
|
10
|
+
return {
|
|
11
|
+
sha: c.sha,
|
|
12
|
+
authorDate: c.authorDate,
|
|
13
|
+
subject: subjects.get(c.sha) ?? '',
|
|
14
|
+
touchesCodePath: paths.some((p) => !isOwnedPath(p, file)),
|
|
15
|
+
hasNodeTrailer: trailered.has(c.sha),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
}
|
package/dist/git/run.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
/** Run git in `repoDir` with the given args (no shell). Throws on non-zero exit. */
|
|
3
|
+
export function runGit(repoDir, args) {
|
|
4
|
+
return execFileSync('git', ['-C', repoDir, ...args], {
|
|
5
|
+
encoding: 'utf8',
|
|
6
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
7
|
+
});
|
|
8
|
+
}
|
package/dist/ids.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ParsedThreaditFile, ParsedInboxFile } from '@threadit/core';
|
|
2
|
+
export declare function slugify(text: string): string;
|
|
3
|
+
export declare function uniqueNodeId(file: ParsedThreaditFile, base: string): string;
|
|
4
|
+
export declare function uniqueInboxId(inbox: ParsedInboxFile): string;
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function slugify(text) {
|
|
2
|
+
return text.toLowerCase().trim()
|
|
3
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
4
|
+
.replace(/^-+|-+$/g, '')
|
|
5
|
+
.slice(0, 40) || 'node';
|
|
6
|
+
}
|
|
7
|
+
export function uniqueNodeId(file, base) {
|
|
8
|
+
const taken = new Set(file.nodes.map((n) => n.id));
|
|
9
|
+
if (!taken.has(base))
|
|
10
|
+
return base;
|
|
11
|
+
for (let i = 2;; i++) {
|
|
12
|
+
const candidate = `${base}-${i}`;
|
|
13
|
+
if (!taken.has(candidate))
|
|
14
|
+
return candidate;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function uniqueInboxId(inbox) {
|
|
18
|
+
let max = 0;
|
|
19
|
+
for (const item of inbox.items) {
|
|
20
|
+
const m = /^i-(\d+)$/.exec(item.id);
|
|
21
|
+
if (m)
|
|
22
|
+
max = Math.max(max, Number(m[1]));
|
|
23
|
+
}
|
|
24
|
+
return `i-${max + 1}`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { InboxItem } from '@threadit/core';
|
|
2
|
+
export declare function normalize(text: string): string;
|
|
3
|
+
/** Dice coefficient over character bigrams of the normalized strings. */
|
|
4
|
+
export declare function diceSimilarity(a: string, b: string): number;
|
|
5
|
+
export declare const RECURRENCE_THRESHOLD = 0.5;
|
|
6
|
+
/** Best open-item match for `text` above the threshold, else undefined. */
|
|
7
|
+
export declare function matchOpenItem(items: InboxItem[], text: string): InboxItem | undefined;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const STOPWORDS = new Set([
|
|
2
|
+
'the', 'and', 'a', 'an', 'when', 'it', 'to', 'or', 'of', 'on', 'in', 'we', 'should', 'also',
|
|
3
|
+
'need', 'needs', 'todo', 'note', 'that', 'this', 'for', 'is', 'be', 'add',
|
|
4
|
+
]);
|
|
5
|
+
// keep meaningful verbs; 'add' carries meaning, so it is NOT a stopword
|
|
6
|
+
STOPWORDS.delete('add');
|
|
7
|
+
export function normalize(text) {
|
|
8
|
+
return text.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
10
|
+
.split(/\s+/)
|
|
11
|
+
.filter((w) => w && !STOPWORDS.has(w))
|
|
12
|
+
.join(' ');
|
|
13
|
+
}
|
|
14
|
+
/** Dice coefficient over character bigrams of the normalized strings. */
|
|
15
|
+
export function diceSimilarity(a, b) {
|
|
16
|
+
const bigrams = (s) => {
|
|
17
|
+
const m = new Map();
|
|
18
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
19
|
+
const bg = s.slice(i, i + 2);
|
|
20
|
+
m.set(bg, (m.get(bg) ?? 0) + 1);
|
|
21
|
+
}
|
|
22
|
+
return m;
|
|
23
|
+
};
|
|
24
|
+
const na = normalize(a).replace(/\s/g, ''), nb = normalize(b).replace(/\s/g, '');
|
|
25
|
+
if (na === nb)
|
|
26
|
+
return 1;
|
|
27
|
+
if (na.length < 2 || nb.length < 2)
|
|
28
|
+
return 0;
|
|
29
|
+
const ba = bigrams(na), bb = bigrams(nb);
|
|
30
|
+
let overlap = 0;
|
|
31
|
+
for (const [bg, count] of ba)
|
|
32
|
+
overlap += Math.min(count, bb.get(bg) ?? 0);
|
|
33
|
+
const total = [...ba.values()].reduce((s, n) => s + n, 0) + [...bb.values()].reduce((s, n) => s + n, 0);
|
|
34
|
+
return (2 * overlap) / total;
|
|
35
|
+
}
|
|
36
|
+
export const RECURRENCE_THRESHOLD = 0.5;
|
|
37
|
+
/** Best open-item match for `text` above the threshold, else undefined. */
|
|
38
|
+
export function matchOpenItem(items, text) {
|
|
39
|
+
let best;
|
|
40
|
+
let bestScore = 0;
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
if (item.status !== 'open')
|
|
43
|
+
continue;
|
|
44
|
+
const score = diceSimilarity(item.text, text);
|
|
45
|
+
if (score >= RECURRENCE_THRESHOLD && score > bestScore) {
|
|
46
|
+
best = item;
|
|
47
|
+
bestScore = score;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return best;
|
|
51
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
/** Write `content` to `path` atomically (temp + rename), creating parent dirs. */
|
|
4
|
+
export function atomicWrite(path, content, mode) {
|
|
5
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
6
|
+
const tmp = `${path}.threadit-tmp-${process.pid}`;
|
|
7
|
+
writeFileSync(tmp, content, mode !== undefined ? { encoding: 'utf8', mode } : 'utf8');
|
|
8
|
+
renameSync(tmp, path);
|
|
9
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Resolve the repo's hooks dir (worktree/submodule/core.hooksPath-safe) to an absolute path. */
|
|
2
|
+
export declare function resolveHooksDir(repoRoot: string): string;
|
|
3
|
+
/** Install/refresh the threadit sync shims. Returns hooks that were warn-skipped (non-shell). */
|
|
4
|
+
export declare function installGitHooks(repoRoot: string): {
|
|
5
|
+
skipped: string[];
|
|
6
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join, isAbsolute } from 'node:path';
|
|
4
|
+
import { atomicWrite } from './atomicWrite.js';
|
|
5
|
+
const BEGIN = '# >>> threadit >>>';
|
|
6
|
+
const END = '# <<< threadit <<<';
|
|
7
|
+
const EVENTS = {
|
|
8
|
+
'post-commit': 'threadit sync',
|
|
9
|
+
'post-merge': 'threadit sync',
|
|
10
|
+
'post-rewrite': 'threadit sync',
|
|
11
|
+
'post-checkout': 'threadit sync',
|
|
12
|
+
};
|
|
13
|
+
/** Resolve the repo's hooks dir (worktree/submodule/core.hooksPath-safe) to an absolute path. */
|
|
14
|
+
export function resolveHooksDir(repoRoot) {
|
|
15
|
+
const out = execFileSync('git', ['-C', repoRoot, 'rev-parse', '--git-path', 'hooks'], { encoding: 'utf8' }).trim();
|
|
16
|
+
return isAbsolute(out) ? out : join(repoRoot, out);
|
|
17
|
+
}
|
|
18
|
+
function block(cmd) {
|
|
19
|
+
// background + detach; NO `exec` (exec replaces the shell, which contradicts the trailing `&`)
|
|
20
|
+
return `${BEGIN}\n${cmd} >/dev/null 2>&1 &\n${END}\n`;
|
|
21
|
+
}
|
|
22
|
+
function isShell(body) {
|
|
23
|
+
const first = body.split('\n', 1)[0] ?? '';
|
|
24
|
+
// No shebang → git runs it via sh. Otherwise chain only onto POSIX-compatible shells
|
|
25
|
+
// (they all understand `cmd >/dev/null 2>&1 &`). NOT fish/csh/tcsh — different syntax;
|
|
26
|
+
// those get warn-skipped rather than corrupted.
|
|
27
|
+
return !first.startsWith('#!') || /\b(sh|bash|dash|zsh|ksh|ash)\b/.test(first);
|
|
28
|
+
}
|
|
29
|
+
/** Install/refresh the threadit sync shims. Returns hooks that were warn-skipped (non-shell). */
|
|
30
|
+
export function installGitHooks(repoRoot) {
|
|
31
|
+
const dir = resolveHooksDir(repoRoot);
|
|
32
|
+
const skipped = [];
|
|
33
|
+
for (const [event, cmd] of Object.entries(EVENTS)) {
|
|
34
|
+
const hookPath = join(dir, event);
|
|
35
|
+
if (!existsSync(hookPath)) {
|
|
36
|
+
atomicWrite(hookPath, `#!/bin/sh\n${block(cmd)}`, 0o755);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const body = readFileSync(hookPath, 'utf8');
|
|
40
|
+
if (body.includes(BEGIN)) {
|
|
41
|
+
// replace ALL marked blocks with one (collapse dups)
|
|
42
|
+
const stripped = body.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}\\n?`, 'g'), '');
|
|
43
|
+
atomicWrite(hookPath, stripped.replace(/\n*$/, '\n') + block(cmd), 0o755);
|
|
44
|
+
}
|
|
45
|
+
else if (isShell(body)) {
|
|
46
|
+
atomicWrite(hookPath, body.replace(/\n*$/, '\n') + block(cmd), 0o755);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
skipped.push(event);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { skipped };
|
|
53
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Ensure `.threadit/` is git-ignored (idempotent; matches an existing equivalent line). */
|
|
2
|
+
export declare function ensureGitignore(repoRoot: string): void;
|
|
3
|
+
/** True iff git considers `relPath` ignored (post-condition check; never throws). */
|
|
4
|
+
export declare function verifyIgnored(repoRoot: string, relPath: string): boolean;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { atomicWrite } from './atomicWrite.js';
|
|
5
|
+
const ENTRY = '.threadit/';
|
|
6
|
+
/** Ensure `.threadit/` is git-ignored (idempotent; matches an existing equivalent line). */
|
|
7
|
+
export function ensureGitignore(repoRoot) {
|
|
8
|
+
const path = join(repoRoot, '.gitignore');
|
|
9
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
10
|
+
const has = existing.split('\n').some((l) => {
|
|
11
|
+
const t = l.trim().replace(/^\//, '');
|
|
12
|
+
return t === ENTRY || t === '.threadit';
|
|
13
|
+
});
|
|
14
|
+
if (has)
|
|
15
|
+
return;
|
|
16
|
+
atomicWrite(path, existing.replace(/\n*$/, existing ? '\n' : '') + ENTRY + '\n');
|
|
17
|
+
}
|
|
18
|
+
/** True iff git considers `relPath` ignored (post-condition check; never throws). */
|
|
19
|
+
export function verifyIgnored(repoRoot, relPath) {
|
|
20
|
+
// git check-ignore: exit 0 = ignored. Exit 1 (not ignored) AND 128 (git error) both throw →
|
|
21
|
+
// we return false, so a misconfig surfaces as a caller warning rather than a silent pass.
|
|
22
|
+
try {
|
|
23
|
+
execFileSync('git', ['-C', repoRoot, 'check-ignore', '-q', relPath]);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The threadit-owned SessionStart commands (inline; call the installed binary). */
|
|
2
|
+
export declare const THREADIT_COMMANDS: {
|
|
3
|
+
sessionStart: string[];
|
|
4
|
+
postToolUse: string[];
|
|
5
|
+
};
|
|
6
|
+
/** Merge threadit's hook groups into .claude/settings.json: own sentinel-tagged groups, foreign untouched. */
|
|
7
|
+
export declare function mergeSettings(repoRoot: string): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { atomicWrite } from './atomicWrite.js';
|
|
4
|
+
const BIN = 'threadit';
|
|
5
|
+
/** The threadit-owned SessionStart commands (inline; call the installed binary). */
|
|
6
|
+
export const THREADIT_COMMANDS = {
|
|
7
|
+
sessionStart: [
|
|
8
|
+
`${BIN} session --bump`,
|
|
9
|
+
`( ${BIN} sync >/dev/null 2>&1 & ) || true`,
|
|
10
|
+
`${BIN} hook capture-directive`,
|
|
11
|
+
],
|
|
12
|
+
postToolUse: [`${BIN} hook validate`, `( ${BIN} sync --draft >/dev/null 2>&1 & ) || true`],
|
|
13
|
+
};
|
|
14
|
+
function cmd(command) { return { type: 'command', command }; }
|
|
15
|
+
function threaditGroup(commands, matcher) {
|
|
16
|
+
return { ...(matcher ? { matcher } : {}), hooks: commands.map(cmd), _threadit: true };
|
|
17
|
+
}
|
|
18
|
+
/** Merge threadit's hook groups into .claude/settings.json: own sentinel-tagged groups, foreign untouched. */
|
|
19
|
+
export function mergeSettings(repoRoot) {
|
|
20
|
+
const path = join(repoRoot, '.claude', 'settings.json');
|
|
21
|
+
let settings = {};
|
|
22
|
+
if (existsSync(path)) {
|
|
23
|
+
try {
|
|
24
|
+
settings = JSON.parse(readFileSync(path, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`malformed JSON in ${path} — refusing to overwrite; fix it and re-run`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
settings.hooks ??= {};
|
|
31
|
+
for (const event of ['SessionStart', 'PostToolUse']) {
|
|
32
|
+
const groups = (settings.hooks[event] ?? []).filter((g) => !g._threadit); // drop our prior groups
|
|
33
|
+
if (event === 'SessionStart')
|
|
34
|
+
groups.push(threaditGroup(THREADIT_COMMANDS.sessionStart));
|
|
35
|
+
else
|
|
36
|
+
groups.push(threaditGroup(THREADIT_COMMANDS.postToolUse, 'Edit|Write|MultiEdit'));
|
|
37
|
+
settings.hooks[event] = groups;
|
|
38
|
+
}
|
|
39
|
+
atomicWrite(path, JSON.stringify(settings, null, 2) + '\n');
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function globalSkillsDir(): string;
|
|
2
|
+
/** Copy both bundled skills to the chosen location and stamp the version marker. */
|
|
3
|
+
export declare function installSkills(repoRoot: string, location: 'global' | 'repo', version: string, globalOverride?: string): void;
|
|
4
|
+
/**
|
|
5
|
+
* The installed pack version this repo is governed by:
|
|
6
|
+
* - skillsDirOverride marker present → return it (for tests / explicit override)
|
|
7
|
+
* - repo skills dir marker present → return it
|
|
8
|
+
* - global skills dir marker present → return it
|
|
9
|
+
* - else the repo config's packVersion (set at init) → undefined if none
|
|
10
|
+
*/
|
|
11
|
+
export declare function readInstalledPackVersion(repoRoot: string, skillsDirOverride?: string): string | undefined;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { skillSourceDir } from '../assets.js';
|
|
5
|
+
import { atomicWrite } from './atomicWrite.js';
|
|
6
|
+
import { readConfig } from '../config.js';
|
|
7
|
+
const MARKER = '.threadit-pack-version';
|
|
8
|
+
export function globalSkillsDir() { return join(homedir(), '.claude', 'skills'); }
|
|
9
|
+
function repoSkillsDir(repoRoot) { return join(repoRoot, '.claude', 'skills'); }
|
|
10
|
+
/** Copy both bundled skills to the chosen location and stamp the version marker. */
|
|
11
|
+
export function installSkills(repoRoot, location, version, globalOverride) {
|
|
12
|
+
const dest = location === 'global' ? (globalOverride ?? globalSkillsDir()) : repoSkillsDir(repoRoot);
|
|
13
|
+
const src = skillSourceDir();
|
|
14
|
+
const skillDirs = readdirSync(src, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
15
|
+
for (const name of skillDirs) {
|
|
16
|
+
const body = readFileSync(join(src, name, 'SKILL.md'), 'utf8');
|
|
17
|
+
atomicWrite(join(dest, name, 'SKILL.md'), body);
|
|
18
|
+
}
|
|
19
|
+
// Stamp the version marker beside the installed skills. `dest` already differentiates global
|
|
20
|
+
// (~/.claude/skills) from repo (.claude/skills), so one unconditional write covers both.
|
|
21
|
+
atomicWrite(join(dest, MARKER), version + '\n');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The installed pack version this repo is governed by:
|
|
25
|
+
* - skillsDirOverride marker present → return it (for tests / explicit override)
|
|
26
|
+
* - repo skills dir marker present → return it
|
|
27
|
+
* - global skills dir marker present → return it
|
|
28
|
+
* - else the repo config's packVersion (set at init) → undefined if none
|
|
29
|
+
*/
|
|
30
|
+
export function readInstalledPackVersion(repoRoot, skillsDirOverride) {
|
|
31
|
+
const candidates = [skillsDirOverride, repoSkillsDir(repoRoot), globalSkillsDir()].filter(Boolean);
|
|
32
|
+
for (const d of candidates) {
|
|
33
|
+
const m = join(d, MARKER);
|
|
34
|
+
if (existsSync(m))
|
|
35
|
+
return readFileSync(m, 'utf8').trim();
|
|
36
|
+
}
|
|
37
|
+
return readConfig(repoRoot).packVersion;
|
|
38
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt for a single choice, reading the answer from /dev/tty (works under `curl … | sh`,
|
|
3
|
+
* where stdin is the piped script). Returns `fallback` when /dev/tty can't be opened
|
|
4
|
+
* (CI / no controlling terminal) after printing a notice.
|
|
5
|
+
*/
|
|
6
|
+
export declare function promptChoice(question: string, choices: string[], fallback: string): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { openSync, readSync, closeSync, writeSync } from 'node:fs';
|
|
2
|
+
/**
|
|
3
|
+
* Prompt for a single choice, reading the answer from /dev/tty (works under `curl … | sh`,
|
|
4
|
+
* where stdin is the piped script). Returns `fallback` when /dev/tty can't be opened
|
|
5
|
+
* (CI / no controlling terminal) after printing a notice.
|
|
6
|
+
*/
|
|
7
|
+
export function promptChoice(question, choices, fallback) {
|
|
8
|
+
let fd;
|
|
9
|
+
try {
|
|
10
|
+
fd = openSync('/dev/tty', 'r');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
process.stderr.write(`${question} — no terminal; defaulting to "${fallback}".\n`);
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
let tty;
|
|
18
|
+
try {
|
|
19
|
+
tty = openSync('/dev/tty', 'w');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
tty = 2;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
writeSync(tty, `${question} [${choices.join('/')}] (${fallback}): `);
|
|
26
|
+
const buf = Buffer.alloc(256); // 256 B is ample for a single-word choice; overflow just falls back to default
|
|
27
|
+
const n = readSync(fd, buf, 0, 256, null);
|
|
28
|
+
const ans = buf.toString('utf8', 0, n).trim().toLowerCase();
|
|
29
|
+
return choices.includes(ans) ? ans : fallback;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
if (tty !== 2)
|
|
33
|
+
closeSync(tty);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/mutate.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ParsedThreaditFile, ParsedInboxFile, RawNode } from '@threadit/core';
|
|
2
|
+
/** Load → apply an in-place mutation to the parsed file → save (guarded by the engine gate). */
|
|
3
|
+
export declare function mutate(path: string, fn: (file: ParsedThreaditFile) => void): ParsedThreaditFile;
|
|
4
|
+
/** Load → apply an in-place mutation to the inbox → save. */
|
|
5
|
+
export declare function mutateInbox(path: string, fn: (inbox: ParsedInboxFile) => void): ParsedInboxFile;
|
|
6
|
+
export declare function findNode(file: ParsedThreaditFile, id: string): RawNode;
|
package/dist/mutate.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { loadThreadit, saveThreadit, loadInbox, saveInbox } from './yaml/io.js';
|
|
2
|
+
/** Load → apply an in-place mutation to the parsed file → save (guarded by the engine gate). */
|
|
3
|
+
export function mutate(path, fn) {
|
|
4
|
+
const file = loadThreadit(path);
|
|
5
|
+
fn(file);
|
|
6
|
+
saveThreadit(path, file);
|
|
7
|
+
return file;
|
|
8
|
+
}
|
|
9
|
+
/** Load → apply an in-place mutation to the inbox → save. */
|
|
10
|
+
export function mutateInbox(path, fn) {
|
|
11
|
+
const inbox = loadInbox(path);
|
|
12
|
+
fn(inbox);
|
|
13
|
+
saveInbox(path, inbox);
|
|
14
|
+
return inbox;
|
|
15
|
+
}
|
|
16
|
+
export function findNode(file, id) {
|
|
17
|
+
const node = file.nodes.find((n) => n.id === id);
|
|
18
|
+
if (!node)
|
|
19
|
+
throw new Error(`No node with id "${id}" in threadit.yml`);
|
|
20
|
+
return node;
|
|
21
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ParsedThreaditFile } from '@threadit/core';
|
|
2
|
+
export declare const THREADIT_FILE = "threadit.yml";
|
|
3
|
+
export declare const INBOX_FILE = "threadit-inbox.yml";
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the repo root. If `explicit` (from --repo) is given, use it verbatim.
|
|
6
|
+
* Otherwise walk up from `cwd` to the nearest dir containing threadit.yml; if none,
|
|
7
|
+
* fall back to the nearest dir containing .git; if neither, use cwd.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveRepoRoot(cwd?: string, explicit?: string): string;
|
|
10
|
+
export declare function resolveThreaditPath(repoRootOrUndefined?: string): string;
|
|
11
|
+
export declare function resolveInboxPath(repoRootOrUndefined?: string): string;
|
|
12
|
+
/** A path is "Threadit-owned" iff it is the yml/inbox file or the project's narrative sidecar. */
|
|
13
|
+
export declare function isOwnedPath(relPath: string, file: ParsedThreaditFile): boolean;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, resolve } from 'node:path';
|
|
3
|
+
export const THREADIT_FILE = 'threadit.yml';
|
|
4
|
+
export const INBOX_FILE = 'threadit-inbox.yml';
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the repo root. If `explicit` (from --repo) is given, use it verbatim.
|
|
7
|
+
* Otherwise walk up from `cwd` to the nearest dir containing threadit.yml; if none,
|
|
8
|
+
* fall back to the nearest dir containing .git; if neither, use cwd.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveRepoRoot(cwd = process.cwd(), explicit) {
|
|
11
|
+
if (explicit)
|
|
12
|
+
return resolve(explicit);
|
|
13
|
+
let dir = resolve(cwd);
|
|
14
|
+
for (;;) {
|
|
15
|
+
if (existsSync(join(dir, THREADIT_FILE)))
|
|
16
|
+
return dir;
|
|
17
|
+
const parent = dirname(dir);
|
|
18
|
+
if (parent === dir)
|
|
19
|
+
break;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
// second pass for .git
|
|
23
|
+
dir = resolve(cwd);
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (existsSync(join(dir, '.git')))
|
|
26
|
+
return dir;
|
|
27
|
+
const parent = dirname(dir);
|
|
28
|
+
if (parent === dir)
|
|
29
|
+
break;
|
|
30
|
+
dir = parent;
|
|
31
|
+
}
|
|
32
|
+
return resolve(cwd);
|
|
33
|
+
}
|
|
34
|
+
export function resolveThreaditPath(repoRootOrUndefined) {
|
|
35
|
+
return join(resolveRepoRoot(process.cwd(), repoRootOrUndefined), THREADIT_FILE);
|
|
36
|
+
}
|
|
37
|
+
export function resolveInboxPath(repoRootOrUndefined) {
|
|
38
|
+
return join(resolveRepoRoot(process.cwd(), repoRootOrUndefined), INBOX_FILE);
|
|
39
|
+
}
|
|
40
|
+
/** A path is "Threadit-owned" iff it is the yml/inbox file or the project's narrative sidecar. */
|
|
41
|
+
export function isOwnedPath(relPath, file) {
|
|
42
|
+
if (relPath === THREADIT_FILE || relPath === INBOX_FILE)
|
|
43
|
+
return true;
|
|
44
|
+
const narrative = file.project.narrative;
|
|
45
|
+
return narrative !== undefined && relPath === narrative;
|
|
46
|
+
}
|