@tricknowtech/context 0.1.1 → 0.3.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/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  ALL_TIERS,
4
4
  LOCAL_TIERS,
5
5
  LocalStore,
6
+ SLASH_COMMAND_PATH,
6
7
  collect,
7
8
  configPath,
8
9
  cwdKey,
@@ -11,18 +12,25 @@ import {
11
12
  ensureGitignoreEntries,
12
13
  findProjectRoot,
13
14
  formatBytes,
15
+ formatHandoff,
14
16
  formatHits,
17
+ handoffAge,
15
18
  installSlashCommand,
16
19
  isGitRepo,
20
+ isStale,
17
21
  loadConfig,
22
+ resolveTemplate,
18
23
  saveConfig,
19
24
  scanFiles,
20
25
  storeDir,
21
26
  summarize,
22
- userClaudeDir
23
- } from "./chunk-BWATZKYM.js";
27
+ templateContextFor,
28
+ userClaudeDir,
29
+ validateHandoff
30
+ } from "./chunk-BLEGQVXA.js";
24
31
 
25
32
  // src/commands.ts
33
+ import { execFileSync } from "child_process";
26
34
  import fs from "fs";
27
35
  import path from "path";
28
36
  function ok(lines) {
@@ -46,7 +54,7 @@ function requireProject() {
46
54
  return { root, cfg };
47
55
  }
48
56
  function templateContext(root) {
49
- return { userClaude: userClaudeDir(), project: root, cwdKey: cwdKey(root) };
57
+ return templateContextFor(root);
50
58
  }
51
59
  function effectiveTiers(cfg) {
52
60
  const tiers = cfg.tiers.filter((t) => LOCAL_TIERS.includes(t));
@@ -82,7 +90,7 @@ function cmdPush(opts = {}) {
82
90
  if ("code" in found) return found;
83
91
  const { root, cfg } = found;
84
92
  const { tiers, refused } = effectiveTiers(cfg);
85
- const { files, skippedTracked } = collect(root, cfg, tiers);
93
+ const { files, skippedTracked, assistants } = collect(root, cfg, tiers);
86
94
  const lines = [];
87
95
  if (refused.length > 0) {
88
96
  lines.push(
@@ -127,6 +135,9 @@ function cmdPush(opts = {}) {
127
135
  const t = totals[tier];
128
136
  if (t.count > 0) lines.push(` ${tier.padEnd(11)} ${String(t.count).padStart(4)} files ${formatBytes(t.bytes)}`);
129
137
  }
138
+ if (assistants.length > 0) {
139
+ lines.push("", `Assistants: ${assistants.map((a) => a.name).join(", ")}`);
140
+ }
130
141
  if (skippedTracked.length > 0) {
131
142
  lines.push("", `${skippedTracked.length} project files skipped \u2014 git already tracks them.`);
132
143
  }
@@ -138,6 +149,20 @@ function cmdPush(opts = {}) {
138
149
  }
139
150
  }
140
151
  if (!opts.dryRun) {
152
+ const store = new LocalStore(root);
153
+ const raw = store.readHandoff();
154
+ const v = raw ? validateHandoff(raw) : null;
155
+ if (!raw) {
156
+ lines.push(
157
+ "",
158
+ "No handoff written \u2014 the other machine will get your project knowledge but",
159
+ "not where you left off. Use `/context push` in Claude Code to include one."
160
+ );
161
+ } else if (v && !v.ok) {
162
+ lines.push("", "Handoff present but malformed (it will be ignored):", ...v.errors.map((e) => ` \xB7 ${e}`));
163
+ } else if (v?.ok && isStale(v.handoff, Date.now())) {
164
+ lines.push("", `Handoff is ${handoffAge(v.handoff)} \u2014 re-run \`/context push\` to refresh it.`);
165
+ }
141
166
  lines.push("", "Commit .contextsync/ to carry this context with the repo.");
142
167
  }
143
168
  return ok(lines);
@@ -167,12 +192,114 @@ function cmdPull(opts = {}) {
167
192
  "Re-run with --force to overwrite them."
168
193
  );
169
194
  }
170
- const handoff = store.readHandoff();
171
- if (handoff) {
172
- lines.push("", `Handoff (${handoff.updatedAt}):`, ` goal: ${handoff.goal}`, ` next: ${handoff.nextStep}`);
195
+ const raw = store.readHandoff();
196
+ if (!raw) {
197
+ lines.push(
198
+ "",
199
+ "No handoff in this store \u2014 you have the project knowledge, but not where the",
200
+ "last session stopped. Run `/context push` (not bare `ctx push`) on the other",
201
+ "machine: only the model can write the handoff, since only it has the conversation."
202
+ );
203
+ return ok(lines);
204
+ }
205
+ const check = validateHandoff(raw);
206
+ if (!check.ok) {
207
+ lines.push("", "A handoff exists but is malformed and was ignored:", ...check.errors.map((e) => ` \xB7 ${e}`));
208
+ return ok(lines);
173
209
  }
210
+ lines.push("", ...formatHandoff(check.handoff));
174
211
  return ok(lines);
175
212
  }
213
+ function cmdHandoff(opts = {}) {
214
+ const found = requireProject();
215
+ if ("code" in found) return found;
216
+ const { root } = found;
217
+ const store = new LocalStore(root);
218
+ if (opts.set !== void 0) {
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(opts.set);
222
+ } catch (e) {
223
+ return fail([`Could not parse handoff JSON: ${e.message}`]);
224
+ }
225
+ const check2 = validateHandoff(parsed);
226
+ if (!check2.ok) return fail(["Handoff is not valid:", ...check2.errors.map((e) => ` \xB7 ${e}`)]);
227
+ store.writeHandoff(check2.handoff);
228
+ return ok([`Handoff saved (${path.relative(root, storeDir(root))}/handoff.json).`, "", ...formatHandoff(check2.handoff)]);
229
+ }
230
+ const raw = store.readHandoff();
231
+ if (!raw) {
232
+ return ok([
233
+ "No handoff yet.",
234
+ "",
235
+ "Write one with `/context push` in Claude Code, or pipe JSON:",
236
+ ` ctx handoff --set '{"goal":"\u2026","nextStep":"\u2026"}'`
237
+ ]);
238
+ }
239
+ const check = validateHandoff(raw);
240
+ if (!check.ok) return fail(["Handoff is malformed:", ...check.errors.map((e) => ` \xB7 ${e}`)]);
241
+ return ok(formatHandoff(check.handoff));
242
+ }
243
+ function cmdDoctor() {
244
+ const root = findProjectRoot();
245
+ if (!root) return fail(["Not inside a project. Run `ctx init` first."]);
246
+ const lines = [];
247
+ let problems = 0;
248
+ const check = (okFlag, label, detail) => {
249
+ if (!okFlag) problems++;
250
+ lines.push(` ${okFlag ? "\u2713" : "\u2717"} ${label.padEnd(28)} ${detail}`);
251
+ };
252
+ const cfg = loadConfig(root);
253
+ check(Boolean(cfg), "store initialised", cfg ? `${path.relative(root, storeDir(root))}/` : "missing \u2014 run `ctx init`");
254
+ if (!cfg) return { code: 1, lines: ["Setup check", "", ...lines] };
255
+ const store = new LocalStore(root);
256
+ const manifest = store.readManifest();
257
+ check(Boolean(manifest), "pushed at least once", manifest ? `${manifest.entries.length} files` : "never \u2014 run `ctx push`");
258
+ const detected = collect(root, cfg, ["core"]).assistants;
259
+ check(
260
+ detected.length > 0,
261
+ "assistants detected",
262
+ detected.length > 0 ? detected.map((a) => a.name).join(", ") : "none \u2014 no known assistant config found"
263
+ );
264
+ check(
265
+ fs.existsSync(path.join(root, SLASH_COMMAND_PATH)),
266
+ "/context slash command",
267
+ fs.existsSync(path.join(root, SLASH_COMMAND_PATH)) ? SLASH_COMMAND_PATH : `missing \u2014 run \`ctx init --force\``
268
+ );
269
+ const inGit = isGitRepo(root);
270
+ check(inGit, "git repository", inGit ? "yes \u2014 store travels with the repo" : "no \u2014 the store will not sync anywhere");
271
+ let storeIgnored = false;
272
+ if (inGit) {
273
+ try {
274
+ execFileSync("git", ["-C", root, "check-ignore", "-q", ".contextsync/config.json"], { stdio: "ignore" });
275
+ storeIgnored = true;
276
+ } catch {
277
+ storeIgnored = false;
278
+ }
279
+ }
280
+ check(!storeIgnored, "store is committable", storeIgnored ? "IGNORED by git \u2014 it will never reach another machine" : "not gitignored");
281
+ const ctx = templateContext(root);
282
+ const memEntry = manifest?.entries.find((e) => e.storePath.startsWith("memory/"));
283
+ if (memEntry) {
284
+ const dest = resolveTemplate(memEntry.restoreTemplate, ctx);
285
+ const expectedDir = path.join(userClaudeDir(), "projects", cwdKey(root), "memory");
286
+ check(dest.startsWith(expectedDir), "memory restore path", dest.startsWith(expectedDir) ? expectedDir : `WRONG \u2192 ${dest}`);
287
+ } else {
288
+ check(false, "memory captured", "none found \u2014 is ~/.claude/projects/<key>/memory populated?");
289
+ }
290
+ const raw = store.readHandoff();
291
+ if (!raw) {
292
+ check(false, "handoff", "absent \u2014 `/context push` writes it; resume will have nothing to say");
293
+ } else {
294
+ const v = validateHandoff(raw);
295
+ check(v.ok, "handoff", v.ok ? `valid, ${handoffAge(v.handoff)}` : v.errors[0]);
296
+ if (v.ok && manifest && isStale(v.handoff, Date.parse(manifest.updatedAt))) {
297
+ lines.push(" ! handoff is much older than the last push \u2014 re-run `/context push`");
298
+ }
299
+ }
300
+ const header = problems === 0 ? "Setup check \u2014 all good" : `Setup check \u2014 ${problems} problem${problems === 1 ? "" : "s"}`;
301
+ return { code: problems === 0 ? 0 : 1, lines: [header, "", ...lines] };
302
+ }
176
303
  function cmdStatus() {
177
304
  const found = requireProject();
178
305
  if ("code" in found) return found;
@@ -180,11 +307,16 @@ function cmdStatus() {
180
307
  const store = new LocalStore(root);
181
308
  const manifest = store.readManifest();
182
309
  const { tiers } = effectiveTiers(cfg);
183
- const { files, skippedTracked } = collect(root, cfg, tiers);
310
+ const { files, skippedTracked, assistants } = collect(root, cfg, tiers);
311
+ const rawHandoff = store.readHandoff();
312
+ const hv = rawHandoff ? validateHandoff(rawHandoff) : null;
313
+ const handoffLabel = !rawHandoff ? "none \u2014 run `/context push` to record where you left off" : hv?.ok ? `${handoffAge(hv.handoff)}` : "malformed (will be ignored)";
184
314
  const lines = [
185
315
  `Project ${cfg.name}`,
186
316
  `Store ${path.relative(root, storeDir(root))}/`,
187
317
  `Tiers ${cfg.tiers.join(", ")}`,
318
+ `Handoff ${handoffLabel}`,
319
+ `Tools ${assistants.length > 0 ? assistants.map((a) => a.name).join(", ") : "none detected"}`,
188
320
  `Remotes ${Object.keys(cfg.remotes).length > 0 ? Object.keys(cfg.remotes).join(", ") : "none (local only)"}`,
189
321
  ""
190
322
  ];
@@ -226,13 +358,16 @@ function cmdStatus() {
226
358
  }
227
359
 
228
360
  // src/cli.ts
361
+ var VERSION = "0.3.0";
229
362
  var USAGE = `tricknowtech context-sync \u2014 carry a project's LLM context between machines
230
363
 
231
364
  Usage
232
365
  ctx init [--artifacts] [--force] Create the store and install /context
233
366
  ctx push [--dry-run] Collect context into the store
234
- ctx pull [--force] Restore context from the store
235
- ctx status Show what has changed since the last push
367
+ ctx pull [--force] Restore context, and show where you left off
368
+ ctx status What has changed since the last push
369
+ ctx handoff [--set '<json>'] Show or write the session handoff
370
+ ctx doctor Check the setup is actually wired correctly
236
371
 
237
372
  Options
238
373
  --artifacts Include derived indexes (graphify-out/, etc.)
@@ -243,20 +378,38 @@ Options
243
378
  -v, --version Show version
244
379
 
245
380
  The store lives in .contextsync/ and is meant to be committed, so context
246
- travels with the code. Session transcripts are excluded from local mode.`;
381
+ travels with the code. Session transcripts are excluded from local mode.
382
+
383
+ Prefer \`/context push\` inside Claude Code over bare \`ctx push\`: only the
384
+ model can write the handoff that lets the next machine resume the work.`;
247
385
  function parseArgs(argv) {
248
386
  const flags = /* @__PURE__ */ new Set();
387
+ const values = /* @__PURE__ */ new Map();
249
388
  let command = "";
250
- for (const arg of argv) {
251
- if (arg.startsWith("-")) flags.add(arg.replace(/^-+/, ""));
252
- else if (!command) command = arg;
389
+ for (let i = 0; i < argv.length; i++) {
390
+ const arg = argv[i];
391
+ if (arg.startsWith("-")) {
392
+ const name = arg.replace(/^-+/, "");
393
+ const eq = name.indexOf("=");
394
+ if (eq !== -1) {
395
+ values.set(name.slice(0, eq), name.slice(eq + 1));
396
+ continue;
397
+ }
398
+ if (name === "set" && i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
399
+ values.set(name, argv[++i]);
400
+ continue;
401
+ }
402
+ flags.add(name);
403
+ } else if (!command) {
404
+ command = arg;
405
+ }
253
406
  }
254
- return { command, flags };
407
+ return { command, flags, values };
255
408
  }
256
409
  function run(argv) {
257
- const { command, flags } = parseArgs(argv);
410
+ const { command, flags, values } = parseArgs(argv);
258
411
  if (flags.has("h") || flags.has("help")) return { code: 0, lines: [USAGE] };
259
- if (flags.has("v") || flags.has("version")) return { code: 0, lines: ["0.1.1"] };
412
+ if (flags.has("v") || flags.has("version")) return { code: 0, lines: [VERSION] };
260
413
  switch (command) {
261
414
  case "init":
262
415
  return cmdInit({ artifacts: flags.has("artifacts"), force: flags.has("force") });
@@ -264,6 +417,10 @@ function run(argv) {
264
417
  return cmdPush({ allowSecrets: flags.has("allow-secrets"), dryRun: flags.has("dry-run") });
265
418
  case "pull":
266
419
  return cmdPull({ force: flags.has("force") });
420
+ case "handoff":
421
+ return cmdHandoff({ set: values.get("set") });
422
+ case "doctor":
423
+ return cmdDoctor();
267
424
  case "status":
268
425
  case "":
269
426
  return cmdStatus();