@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 +28 -0
- package/README.md +44 -0
- package/dist/index.js +174 -10
- package/package.json +6 -1
- package/src/commands/login.ts +13 -2
- package/src/commands/status.ts +180 -0
- package/src/commands/sync.ts +2 -2
- package/src/index.ts +12 -3
- package/src/lib/config.ts +70 -10
- package/dist/residue +0 -0
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
|
|
3898
|
+
function readConfigFromPath(configPath) {
|
|
3899
3899
|
return ResultAsync.fromPromise((async () => {
|
|
3900
|
-
const
|
|
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
|
|
3908
|
+
function writeConfigToPath(opts) {
|
|
3910
3909
|
return ResultAsync.fromPromise((async () => {
|
|
3911
|
-
const dir =
|
|
3910
|
+
const dir = join4(opts.configPath, "..");
|
|
3912
3911
|
await mkdir4(dir, { recursive: true });
|
|
3913
|
-
await Bun.write(
|
|
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
|
-
|
|
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*
|
|
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(
|
|
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
package/src/commands/login.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { err, ok, okAsync, ResultAsync, safeTry } from "neverthrow";
|
|
2
|
-
import {
|
|
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*
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
33
|
+
function readConfigFromPath(
|
|
34
|
+
configPath: string,
|
|
35
|
+
): ResultAsync<ResidueConfig | null, CliError> {
|
|
30
36
|
return ResultAsync.fromPromise(
|
|
31
37
|
(async () => {
|
|
32
|
-
const
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 =
|
|
54
|
+
const dir = join(opts.configPath, "..");
|
|
49
55
|
await mkdir(dir, { recursive: true });
|
|
50
|
-
await Bun.write(
|
|
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
|