@residue/cli 0.0.1 → 0.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # @residue/cli
2
+
3
+ ## 0.0.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Add status command, common workflows docs, versioning policy.
8
+
9
+ ## 0.0.2
10
+
11
+ ### Patch Changes
12
+
13
+ - Add per-project login support with `--local` flag. Running `residue login --local` saves config to `.residue/config` in the project root instead of the global `~/.residue/config`. The sync command now resolves config locally first before falling back to global.
14
+
15
+ ## 0.0.1
16
+
17
+ ### Features
18
+
19
+ - Initial release
20
+ - `residue login` to save worker URL and auth token
21
+ - `residue init` to install git hooks (post-commit, pre-push)
22
+ - `residue setup` for agent adapters (claude-code, pi)
23
+ - `residue capture` for post-commit session tagging
24
+ - `residue sync` for pre-push session upload via presigned R2 URLs
25
+ - `residue push` as manual sync alias
26
+ - `residue session start` and `residue session end` for agent adapters
27
+ - `residue hook claude-code` for Claude Code native hook protocol
28
+ - Claude Code and Pi agent adapters
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @residue/cli
2
+
3
+ CLI that captures AI agent conversations and links them to git commits.
4
+
5
+ Part of [residue](https://residue.dev) -- see the full docs at [residue.dev](https://residue.dev).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add -g @residue/cli
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # Save your worker URL and auth token
17
+ residue login --url https://your-worker.workers.dev --token YOUR_TOKEN
18
+
19
+ # Install git hooks in your repo
20
+ residue init
21
+
22
+ # Set up an agent adapter
23
+ residue setup claude-code # for Claude Code
24
+ residue setup pi # for pi coding agent
25
+ ```
26
+
27
+ After setup, conversations are captured automatically. Commit and push as usual.
28
+
29
+ ## Commands
30
+
31
+ | Command | Description |
32
+ |---|---|
33
+ | `residue login` | Save worker URL + auth token |
34
+ | `residue init` | Install git hooks (post-commit, pre-push) |
35
+ | `residue setup <agent>` | Configure an agent adapter |
36
+ | `residue push` | Manually upload pending sessions |
37
+ | `residue capture` | Tag pending sessions with current commit (hook) |
38
+ | `residue sync` | Upload sessions to worker (hook) |
39
+ | `residue session start` | Register a new session (adapter) |
40
+ | `residue session end` | Mark a session as ended (adapter) |
41
+
42
+ ## License
43
+
44
+ MIT
package/dist/index.js CHANGED
@@ -3895,10 +3895,9 @@ function getConfigDir() {
3895
3895
  function getConfigPath() {
3896
3896
  return join4(getConfigDir(), "config");
3897
3897
  }
3898
- function readConfig() {
3898
+ function readConfigFromPath(configPath) {
3899
3899
  return ResultAsync.fromPromise((async () => {
3900
- const path = getConfigPath();
3901
- const file = Bun.file(path);
3900
+ const file = Bun.file(configPath);
3902
3901
  const isExists = await file.exists();
3903
3902
  if (!isExists)
3904
3903
  return null;
@@ -3906,13 +3905,36 @@ function readConfig() {
3906
3905
  return JSON.parse(text);
3907
3906
  })(), toCliError({ message: "Failed to read config", code: "CONFIG_ERROR" }));
3908
3907
  }
3909
- function writeConfig(config) {
3908
+ function writeConfigToPath(opts) {
3910
3909
  return ResultAsync.fromPromise((async () => {
3911
- const dir = getConfigDir();
3910
+ const dir = join4(opts.configPath, "..");
3912
3911
  await mkdir4(dir, { recursive: true });
3913
- await Bun.write(getConfigPath(), JSON.stringify(config, null, 2));
3912
+ await Bun.write(opts.configPath, JSON.stringify(opts.config, null, 2));
3914
3913
  })(), toCliError({ message: "Failed to write config", code: "CONFIG_ERROR" }));
3915
3914
  }
3915
+ function readConfig() {
3916
+ return readConfigFromPath(getConfigPath());
3917
+ }
3918
+ function writeConfig(config) {
3919
+ return writeConfigToPath({ configPath: getConfigPath(), config });
3920
+ }
3921
+ function readLocalConfig(projectRoot) {
3922
+ return readConfigFromPath(join4(projectRoot, ".residue", "config"));
3923
+ }
3924
+ function writeLocalConfig(opts) {
3925
+ return writeConfigToPath({
3926
+ configPath: join4(opts.projectRoot, ".residue", "config"),
3927
+ config: opts.config
3928
+ });
3929
+ }
3930
+ function resolveConfig() {
3931
+ return getProjectRoot().andThen((projectRoot) => readLocalConfig(projectRoot)).orElse(() => okAsync(null)).andThen((localConfig) => {
3932
+ if (localConfig !== null) {
3933
+ return okAsync(localConfig);
3934
+ }
3935
+ return readConfig();
3936
+ });
3937
+ }
3916
3938
 
3917
3939
  // src/commands/login.ts
3918
3940
  var log4 = createLogger("login");
@@ -3924,7 +3946,13 @@ function login(opts) {
3924
3946
  }));
3925
3947
  }
3926
3948
  const cleanUrl = opts.url.replace(/\/+$/, "");
3927
- return writeConfig({ worker_url: cleanUrl, token: opts.token }).map(() => {
3949
+ const config = { worker_url: cleanUrl, token: opts.token };
3950
+ if (opts.isLocal) {
3951
+ return getProjectRoot().andThen((projectRoot) => writeLocalConfig({ projectRoot, config }).map(() => {
3952
+ log4.info(`Logged in to ${cleanUrl} (project-local config)`);
3953
+ }));
3954
+ }
3955
+ return writeConfig(config).map(() => {
3928
3956
  log4.info(`Logged in to ${cleanUrl}`);
3929
3957
  });
3930
3958
  }
@@ -4117,7 +4145,7 @@ function resolveRemote(remoteUrl) {
4117
4145
  }
4118
4146
  function sync(opts) {
4119
4147
  return safeTry(async function* () {
4120
- const config = yield* readConfig();
4148
+ const config = yield* resolveConfig();
4121
4149
  if (!config) {
4122
4150
  return err(new CliError({
4123
4151
  message: "Not configured. Run 'residue login' first.",
@@ -4422,10 +4450,145 @@ function setup(opts) {
4422
4450
  });
4423
4451
  }
4424
4452
 
4453
+ // src/commands/status.ts
4454
+ import { readFile as readFile4, stat as stat5 } from "fs/promises";
4455
+ import { join as join6 } from "path";
4456
+ var log9 = createLogger("status");
4457
+ function checkFileExists(path) {
4458
+ return ResultAsync.fromPromise(stat5(path).then(() => true), toCliError({ message: "Failed to check file", code: "IO_ERROR" })).orElse(() => okAsync(false));
4459
+ }
4460
+ function checkHookInstalled(opts) {
4461
+ const hookPath = join6(opts.gitDir, "hooks", opts.hookName);
4462
+ return ResultAsync.fromPromise(readFile4(hookPath, "utf-8").then((content) => content.includes(opts.needle)), toCliError({ message: "Failed to read hook", code: "IO_ERROR" })).orElse(() => okAsync(false));
4463
+ }
4464
+ function getGitDir() {
4465
+ return ResultAsync.fromPromise((async () => {
4466
+ const proc = Bun.spawn(["git", "rev-parse", "--git-dir"], {
4467
+ stdout: "pipe",
4468
+ stderr: "pipe"
4469
+ });
4470
+ await proc.exited;
4471
+ return (await new Response(proc.stdout).text()).trim();
4472
+ })(), toCliError({ message: "Failed to get git directory", code: "GIT_ERROR" }));
4473
+ }
4474
+ function status() {
4475
+ return safeTry(async function* () {
4476
+ const isRepo = yield* isGitRepo();
4477
+ if (!isRepo) {
4478
+ log9.info("Not a git repository.");
4479
+ return ok(undefined);
4480
+ }
4481
+ const projectRoot = yield* getProjectRoot();
4482
+ log9.info("Login");
4483
+ const globalConfig = yield* readConfig();
4484
+ if (globalConfig) {
4485
+ log9.info(` global: ${globalConfig.worker_url}`);
4486
+ } else {
4487
+ log9.info(" global: not configured");
4488
+ }
4489
+ const localConfig = yield* readLocalConfig(projectRoot);
4490
+ if (localConfig) {
4491
+ log9.info(` local: ${localConfig.worker_url}`);
4492
+ } else {
4493
+ log9.info(" local: not configured");
4494
+ }
4495
+ const isActiveConfig = localConfig ?? globalConfig;
4496
+ if (isActiveConfig) {
4497
+ log9.info(` active: ${isActiveConfig.worker_url}`);
4498
+ } else {
4499
+ log9.info(' active: none (run "residue login" to configure)');
4500
+ }
4501
+ log9.info("");
4502
+ log9.info("Hooks");
4503
+ const gitDir = yield* getGitDir();
4504
+ const isPostCommitInstalled = yield* checkHookInstalled({
4505
+ gitDir,
4506
+ hookName: "post-commit",
4507
+ needle: "residue capture"
4508
+ });
4509
+ log9.info(` post-commit: ${isPostCommitInstalled ? "installed" : "not installed"}`);
4510
+ const isPrePushInstalled = yield* checkHookInstalled({
4511
+ gitDir,
4512
+ hookName: "pre-push",
4513
+ needle: "residue sync"
4514
+ });
4515
+ log9.info(` pre-push: ${isPrePushInstalled ? "installed" : "not installed"}`);
4516
+ if (!isPostCommitInstalled || !isPrePushInstalled) {
4517
+ log9.info(' run "residue init" to install missing hooks');
4518
+ }
4519
+ log9.info("");
4520
+ log9.info("Adapters");
4521
+ const isClaudeSetup = yield* checkFileExists(join6(projectRoot, ".claude", "settings.json"));
4522
+ let isClaudeHookConfigured = false;
4523
+ if (isClaudeSetup) {
4524
+ isClaudeHookConfigured = yield* ResultAsync.fromPromise(readFile4(join6(projectRoot, ".claude", "settings.json"), "utf-8").then((content) => content.includes("residue hook claude-code")), toCliError({
4525
+ message: "Failed to read claude settings",
4526
+ code: "IO_ERROR"
4527
+ })).orElse(() => okAsync(false));
4528
+ }
4529
+ log9.info(` claude-code: ${isClaudeHookConfigured ? "configured" : "not configured"}`);
4530
+ const isPiSetup = yield* checkFileExists(join6(projectRoot, ".pi", "extensions", "residue.ts"));
4531
+ log9.info(` pi: ${isPiSetup ? "configured" : "not configured"}`);
4532
+ log9.info("");
4533
+ log9.info("Sessions");
4534
+ const pendingPath = yield* getPendingPath(projectRoot);
4535
+ const sessions = yield* readPending(pendingPath);
4536
+ if (sessions.length === 0) {
4537
+ log9.info(" no pending sessions");
4538
+ } else {
4539
+ const openSessions = sessions.filter((s) => s.status === "open");
4540
+ const endedSessions = sessions.filter((s) => s.status === "ended");
4541
+ const totalCommits = sessions.reduce((sum, s) => sum + s.commits.length, 0);
4542
+ const sessionsWithCommits = sessions.filter((s) => s.commits.length > 0);
4543
+ log9.info(` total: ${sessions.length}`);
4544
+ log9.info(` open: ${openSessions.length}`);
4545
+ log9.info(` ended: ${endedSessions.length}`);
4546
+ log9.info(` commits: ${totalCommits} across ${sessionsWithCommits.length} session(s)`);
4547
+ const isReadyToSync = sessionsWithCommits.length > 0;
4548
+ if (isReadyToSync) {
4549
+ log9.info(` ${sessionsWithCommits.length} session(s) ready to sync on next push`);
4550
+ } else {
4551
+ log9.info(" no sessions ready to sync (no commits captured yet)");
4552
+ }
4553
+ }
4554
+ return ok(undefined);
4555
+ });
4556
+ }
4557
+ // package.json
4558
+ var package_default = {
4559
+ name: "@residue/cli",
4560
+ version: "0.0.3",
4561
+ repository: {
4562
+ type: "git",
4563
+ url: "https://github.com/butttons/residue",
4564
+ directory: "packages/cli"
4565
+ },
4566
+ type: "module",
4567
+ bin: {
4568
+ residue: "./dist/index.js"
4569
+ },
4570
+ scripts: {
4571
+ dev: "bun run src/index.ts",
4572
+ build: "bun build src/index.ts --outfile dist/index.js --target bun",
4573
+ test: "bun test",
4574
+ typecheck: "tsc --noEmit"
4575
+ },
4576
+ devDependencies: {
4577
+ "@types/bun": "latest",
4578
+ "@types/debug": "^4.1.12",
4579
+ typescript: "^5.7.0"
4580
+ },
4581
+ dependencies: {
4582
+ commander: "^14.0.3",
4583
+ debug: "^4.4.3",
4584
+ neverthrow: "^8.2.0"
4585
+ }
4586
+ };
4587
+
4425
4588
  // src/index.ts
4426
4589
  var program2 = new Command;
4427
- program2.name("residue").description("Capture AI agent conversations linked to git commits").version("0.0.1");
4428
- program2.command("login").description("Save worker URL and auth token").requiredOption("--url <worker_url>", "Worker URL").requiredOption("--token <auth_token>", "Auth token").action(wrapCommand((opts) => login({ url: opts.url, token: opts.token })));
4590
+ program2.name("residue").description("Capture AI agent conversations linked to git commits").version(package_default.version);
4591
+ program2.command("login").description("Save worker URL and auth token").requiredOption("--url <worker_url>", "Worker URL").requiredOption("--token <auth_token>", "Auth token").option("--local", "Save config to this project instead of globally").action(wrapCommand((opts) => login({ url: opts.url, token: opts.token, isLocal: opts.local })));
4429
4592
  program2.command("init").description("Install git hooks in current repo").action(wrapCommand(() => init()));
4430
4593
  program2.command("setup").description("Configure an agent adapter for this project").argument("<agent>", "Agent to set up (claude-code, pi)").action(wrapCommand((agent) => setup({ agent })));
4431
4594
  var hook = program2.command("hook").description("Agent hook handlers (called by agent plugins)");
@@ -4440,4 +4603,5 @@ session.command("end").description("Mark an agent session as ended").requiredOpt
4440
4603
  program2.command("capture").description("Tag pending sessions with current commit SHA (called by post-commit hook)").action(wrapHookCommand(() => capture()));
4441
4604
  program2.command("sync").description("Upload pending sessions to worker (called by pre-push hook)").option("--remote-url <url>", "Remote URL (passed by pre-push hook)").action(wrapHookCommand((opts) => sync({ remoteUrl: opts.remoteUrl })));
4442
4605
  program2.command("push").description("Upload pending sessions to worker (manual trigger)").action(wrapCommand(() => push()));
4606
+ program2.command("status").description("Show current residue state for this project").action(wrapCommand(() => status()));
4443
4607
  program2.parse();
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@residue/cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/butttons/residue",
7
+ "directory": "packages/cli"
8
+ },
4
9
  "type": "module",
5
10
  "bin": {
6
11
  "residue": "./dist/index.js"
@@ -1,5 +1,6 @@
1
1
  import { errAsync, type ResultAsync } from "neverthrow";
2
- import { writeConfig } from "@/lib/config";
2
+ import { writeConfig, writeLocalConfig } from "@/lib/config";
3
+ import { getProjectRoot } from "@/lib/pending";
3
4
  import { CliError } from "@/utils/errors";
4
5
  import { createLogger } from "@/utils/logger";
5
6
 
@@ -8,6 +9,7 @@ const log = createLogger("login");
8
9
  export function login(opts: {
9
10
  url: string;
10
11
  token: string;
12
+ isLocal?: boolean;
11
13
  }): ResultAsync<void, CliError> {
12
14
  if (!opts.url.startsWith("http://") && !opts.url.startsWith("https://")) {
13
15
  return errAsync(
@@ -19,8 +21,17 @@ export function login(opts: {
19
21
  }
20
22
 
21
23
  const cleanUrl = opts.url.replace(/\/+$/, "");
24
+ const config = { worker_url: cleanUrl, token: opts.token };
22
25
 
23
- return writeConfig({ worker_url: cleanUrl, token: opts.token }).map(() => {
26
+ if (opts.isLocal) {
27
+ return getProjectRoot().andThen((projectRoot) =>
28
+ writeLocalConfig({ projectRoot, config }).map(() => {
29
+ log.info(`Logged in to ${cleanUrl} (project-local config)`);
30
+ }),
31
+ );
32
+ }
33
+
34
+ return writeConfig(config).map(() => {
24
35
  log.info(`Logged in to ${cleanUrl}`);
25
36
  });
26
37
  }
@@ -0,0 +1,180 @@
1
+ import { readFile, stat } from "fs/promises";
2
+ import { ok, okAsync, ResultAsync, safeTry } from "neverthrow";
3
+ import { join } from "path";
4
+ import { readConfig, readLocalConfig } from "@/lib/config";
5
+ import { isGitRepo } from "@/lib/git";
6
+ import { getPendingPath, getProjectRoot, readPending } from "@/lib/pending";
7
+ import type { CliError } from "@/utils/errors";
8
+ import { toCliError } from "@/utils/errors";
9
+ import { createLogger } from "@/utils/logger";
10
+
11
+ const log = createLogger("status");
12
+
13
+ function checkFileExists(path: string): ResultAsync<boolean, CliError> {
14
+ return ResultAsync.fromPromise(
15
+ stat(path).then(() => true),
16
+ toCliError({ message: "Failed to check file", code: "IO_ERROR" }),
17
+ ).orElse(() => okAsync(false));
18
+ }
19
+
20
+ function checkHookInstalled(opts: {
21
+ gitDir: string;
22
+ hookName: string;
23
+ needle: string;
24
+ }): ResultAsync<boolean, CliError> {
25
+ const hookPath = join(opts.gitDir, "hooks", opts.hookName);
26
+ return ResultAsync.fromPromise(
27
+ readFile(hookPath, "utf-8").then((content) =>
28
+ content.includes(opts.needle),
29
+ ),
30
+ toCliError({ message: "Failed to read hook", code: "IO_ERROR" }),
31
+ ).orElse(() => okAsync(false));
32
+ }
33
+
34
+ function getGitDir(): ResultAsync<string, CliError> {
35
+ return ResultAsync.fromPromise(
36
+ (async () => {
37
+ const proc = Bun.spawn(["git", "rev-parse", "--git-dir"], {
38
+ stdout: "pipe",
39
+ stderr: "pipe",
40
+ });
41
+ await proc.exited;
42
+ return (await new Response(proc.stdout).text()).trim();
43
+ })(),
44
+ toCliError({ message: "Failed to get git directory", code: "GIT_ERROR" }),
45
+ );
46
+ }
47
+
48
+ export function status(): ResultAsync<void, CliError> {
49
+ return safeTry(async function* () {
50
+ const isRepo = yield* isGitRepo();
51
+ if (!isRepo) {
52
+ log.info("Not a git repository.");
53
+ return ok(undefined);
54
+ }
55
+
56
+ const projectRoot = yield* getProjectRoot();
57
+
58
+ // -- Auth / Login state --
59
+ log.info("Login");
60
+
61
+ const globalConfig = yield* readConfig();
62
+ if (globalConfig) {
63
+ log.info(` global: ${globalConfig.worker_url}`);
64
+ } else {
65
+ log.info(" global: not configured");
66
+ }
67
+
68
+ const localConfig = yield* readLocalConfig(projectRoot);
69
+ if (localConfig) {
70
+ log.info(` local: ${localConfig.worker_url}`);
71
+ } else {
72
+ log.info(" local: not configured");
73
+ }
74
+
75
+ const isActiveConfig = localConfig ?? globalConfig;
76
+ if (isActiveConfig) {
77
+ log.info(` active: ${isActiveConfig.worker_url}`);
78
+ } else {
79
+ log.info(' active: none (run "residue login" to configure)');
80
+ }
81
+
82
+ log.info("");
83
+
84
+ // -- Git hooks --
85
+ log.info("Hooks");
86
+
87
+ const gitDir = yield* getGitDir();
88
+
89
+ const isPostCommitInstalled = yield* checkHookInstalled({
90
+ gitDir,
91
+ hookName: "post-commit",
92
+ needle: "residue capture",
93
+ });
94
+ log.info(
95
+ ` post-commit: ${isPostCommitInstalled ? "installed" : "not installed"}`,
96
+ );
97
+
98
+ const isPrePushInstalled = yield* checkHookInstalled({
99
+ gitDir,
100
+ hookName: "pre-push",
101
+ needle: "residue sync",
102
+ });
103
+ log.info(
104
+ ` pre-push: ${isPrePushInstalled ? "installed" : "not installed"}`,
105
+ );
106
+
107
+ if (!isPostCommitInstalled || !isPrePushInstalled) {
108
+ log.info(' run "residue init" to install missing hooks');
109
+ }
110
+
111
+ log.info("");
112
+
113
+ // -- Agent adapters --
114
+ log.info("Adapters");
115
+
116
+ const isClaudeSetup = yield* checkFileExists(
117
+ join(projectRoot, ".claude", "settings.json"),
118
+ );
119
+
120
+ let isClaudeHookConfigured = false;
121
+ if (isClaudeSetup) {
122
+ isClaudeHookConfigured = yield* ResultAsync.fromPromise(
123
+ readFile(join(projectRoot, ".claude", "settings.json"), "utf-8").then(
124
+ (content) => content.includes("residue hook claude-code"),
125
+ ),
126
+ toCliError({
127
+ message: "Failed to read claude settings",
128
+ code: "IO_ERROR",
129
+ }),
130
+ ).orElse(() => okAsync(false));
131
+ }
132
+
133
+ log.info(
134
+ ` claude-code: ${isClaudeHookConfigured ? "configured" : "not configured"}`,
135
+ );
136
+
137
+ const isPiSetup = yield* checkFileExists(
138
+ join(projectRoot, ".pi", "extensions", "residue.ts"),
139
+ );
140
+ log.info(` pi: ${isPiSetup ? "configured" : "not configured"}`);
141
+
142
+ log.info("");
143
+
144
+ // -- Pending sessions --
145
+ log.info("Sessions");
146
+
147
+ const pendingPath = yield* getPendingPath(projectRoot);
148
+ const sessions = yield* readPending(pendingPath);
149
+
150
+ if (sessions.length === 0) {
151
+ log.info(" no pending sessions");
152
+ } else {
153
+ const openSessions = sessions.filter((s) => s.status === "open");
154
+ const endedSessions = sessions.filter((s) => s.status === "ended");
155
+ const totalCommits = sessions.reduce(
156
+ (sum, s) => sum + s.commits.length,
157
+ 0,
158
+ );
159
+ const sessionsWithCommits = sessions.filter((s) => s.commits.length > 0);
160
+
161
+ log.info(` total: ${sessions.length}`);
162
+ log.info(` open: ${openSessions.length}`);
163
+ log.info(` ended: ${endedSessions.length}`);
164
+ log.info(
165
+ ` commits: ${totalCommits} across ${sessionsWithCommits.length} session(s)`,
166
+ );
167
+
168
+ const isReadyToSync = sessionsWithCommits.length > 0;
169
+ if (isReadyToSync) {
170
+ log.info(
171
+ ` ${sessionsWithCommits.length} session(s) ready to sync on next push`,
172
+ );
173
+ } else {
174
+ log.info(" no sessions ready to sync (no commits captured yet)");
175
+ }
176
+ }
177
+
178
+ return ok(undefined);
179
+ });
180
+ }
@@ -1,5 +1,5 @@
1
1
  import { err, ok, okAsync, ResultAsync, safeTry } from "neverthrow";
2
- import { readConfig } from "@/lib/config";
2
+ import { resolveConfig } from "@/lib/config";
3
3
  import { getCommitMeta, getRemoteUrl, parseRemote } from "@/lib/git";
4
4
  import type { CommitRef, PendingSession } from "@/lib/pending";
5
5
  import {
@@ -320,7 +320,7 @@ export function sync(opts?: {
320
320
  remoteUrl?: string;
321
321
  }): ResultAsync<void, CliError> {
322
322
  return safeTry(async function* () {
323
- const config = yield* readConfig();
323
+ const config = yield* resolveConfig();
324
324
  if (!config) {
325
325
  return err(
326
326
  new CliError({
package/src/index.ts CHANGED
@@ -9,24 +9,28 @@ import { push } from "@/commands/push";
9
9
  import { sessionEnd } from "@/commands/session-end";
10
10
  import { sessionStart } from "@/commands/session-start";
11
11
  import { setup } from "@/commands/setup";
12
+ import { status } from "@/commands/status";
12
13
  import { sync } from "@/commands/sync";
13
14
  import { wrapCommand, wrapHookCommand } from "@/utils/errors";
14
15
 
16
+ import packageJson from "../package.json";
17
+
15
18
  const program = new Command();
16
19
 
17
20
  program
18
21
  .name("residue")
19
22
  .description("Capture AI agent conversations linked to git commits")
20
- .version("0.0.1");
23
+ .version(packageJson.version);
21
24
 
22
25
  program
23
26
  .command("login")
24
27
  .description("Save worker URL and auth token")
25
28
  .requiredOption("--url <worker_url>", "Worker URL")
26
29
  .requiredOption("--token <auth_token>", "Auth token")
30
+ .option("--local", "Save config to this project instead of globally")
27
31
  .action(
28
- wrapCommand((opts: { url: string; token: string }) =>
29
- login({ url: opts.url, token: opts.token }),
32
+ wrapCommand((opts: { url: string; token: string; local?: boolean }) =>
33
+ login({ url: opts.url, token: opts.token, isLocal: opts.local }),
30
34
  ),
31
35
  );
32
36
 
@@ -96,4 +100,9 @@ program
96
100
  .description("Upload pending sessions to worker (manual trigger)")
97
101
  .action(wrapCommand(() => push()));
98
102
 
103
+ program
104
+ .command("status")
105
+ .description("Show current residue state for this project")
106
+ .action(wrapCommand(() => status()));
107
+
99
108
  program.parse();
package/src/lib/config.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Config management for the residue CLI.
3
- * Manages ~/.residue/config (JSON file).
3
+ * Global config: ~/.residue/config
4
+ * Local (per-project) config: .residue/config (in project root)
5
+ *
6
+ * resolveConfig() checks local first, then falls back to global.
4
7
  */
5
8
 
6
9
  import { mkdir } from "fs/promises";
7
- import { ResultAsync } from "neverthrow";
10
+ import { okAsync, ResultAsync } from "neverthrow";
8
11
  import { join } from "path";
12
+ import { getProjectRoot } from "@/lib/pending";
9
13
  import type { CliError } from "@/utils/errors";
10
14
  import { toCliError } from "@/utils/errors";
11
15
 
@@ -26,11 +30,12 @@ export function getConfigPath(): string {
26
30
  return join(getConfigDir(), "config");
27
31
  }
28
32
 
29
- export function readConfig(): ResultAsync<ResidueConfig | null, CliError> {
33
+ function readConfigFromPath(
34
+ configPath: string,
35
+ ): ResultAsync<ResidueConfig | null, CliError> {
30
36
  return ResultAsync.fromPromise(
31
37
  (async () => {
32
- const path = getConfigPath();
33
- const file = Bun.file(path);
38
+ const file = Bun.file(configPath);
34
39
  const isExists = await file.exists();
35
40
  if (!isExists) return null;
36
41
  const text = await file.text();
@@ -40,19 +45,74 @@ export function readConfig(): ResultAsync<ResidueConfig | null, CliError> {
40
45
  );
41
46
  }
42
47
 
43
- export function writeConfig(
44
- config: ResidueConfig,
45
- ): ResultAsync<void, CliError> {
48
+ function writeConfigToPath(opts: {
49
+ configPath: string;
50
+ config: ResidueConfig;
51
+ }): ResultAsync<void, CliError> {
46
52
  return ResultAsync.fromPromise(
47
53
  (async () => {
48
- const dir = getConfigDir();
54
+ const dir = join(opts.configPath, "..");
49
55
  await mkdir(dir, { recursive: true });
50
- await Bun.write(getConfigPath(), JSON.stringify(config, null, 2));
56
+ await Bun.write(opts.configPath, JSON.stringify(opts.config, null, 2));
51
57
  })(),
52
58
  toCliError({ message: "Failed to write config", code: "CONFIG_ERROR" }),
53
59
  );
54
60
  }
55
61
 
62
+ /**
63
+ * Read the global config from ~/.residue/config.
64
+ */
65
+ export function readConfig(): ResultAsync<ResidueConfig | null, CliError> {
66
+ return readConfigFromPath(getConfigPath());
67
+ }
68
+
69
+ /**
70
+ * Write the global config to ~/.residue/config.
71
+ */
72
+ export function writeConfig(
73
+ config: ResidueConfig,
74
+ ): ResultAsync<void, CliError> {
75
+ return writeConfigToPath({ configPath: getConfigPath(), config });
76
+ }
77
+
78
+ /**
79
+ * Read the local (per-project) config from .residue/config.
80
+ */
81
+ export function readLocalConfig(
82
+ projectRoot: string,
83
+ ): ResultAsync<ResidueConfig | null, CliError> {
84
+ return readConfigFromPath(join(projectRoot, ".residue", "config"));
85
+ }
86
+
87
+ /**
88
+ * Write the local (per-project) config to .residue/config.
89
+ */
90
+ export function writeLocalConfig(opts: {
91
+ projectRoot: string;
92
+ config: ResidueConfig;
93
+ }): ResultAsync<void, CliError> {
94
+ return writeConfigToPath({
95
+ configPath: join(opts.projectRoot, ".residue", "config"),
96
+ config: opts.config,
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Resolve config by checking local (per-project) first, then global.
102
+ * Returns the first one found, or null if neither exists.
103
+ */
104
+ export function resolveConfig(): ResultAsync<ResidueConfig | null, CliError> {
105
+ return getProjectRoot()
106
+ .andThen((projectRoot) => readLocalConfig(projectRoot))
107
+ .orElse(() => okAsync(null as ResidueConfig | null))
108
+ .andThen((localConfig) => {
109
+ if (localConfig !== null) {
110
+ return okAsync(localConfig as ResidueConfig | null);
111
+ }
112
+ return readConfig();
113
+ });
114
+ }
115
+
56
116
  export function configExists(): ResultAsync<boolean, CliError> {
57
117
  return ResultAsync.fromPromise(
58
118
  Bun.file(getConfigPath()).exists(),
package/dist/residue DELETED
Binary file