@projectctx/agent 0.1.0-alpha.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/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @projectctx/agent
2
+
3
+ Local ProjectCtx agent runtime for coding agents. It wraps the internal scanner
4
+ with a small CLI and a stdio MCP server.
5
+
6
+ Friend-alpha setup instructions live in
7
+ [../../docs/alpha/README.md](../../docs/alpha/README.md). Use those docs for the
8
+ current alpha install path, MCP client setup, Code Memory preview/apply, and
9
+ security expectations.
10
+
11
+ ## Friend-alpha install
12
+
13
+ ```bash
14
+ npm install -g @projectctx/agent@alpha
15
+ projectctx login --token <agent-token>
16
+ projectctx doctor
17
+ projectctx scan .
18
+ projectctx mcp
19
+ ```
20
+
21
+ PR 20 publishes public alpha packages only after local pack verification passes.
22
+ Use the `alpha` dist-tag, never `latest`.
23
+
24
+ ## Release verification
25
+
26
+ ```bash
27
+ pnpm --filter @projectctx/agent pack:verify
28
+ ```
29
+
30
+ The verifier builds and packs `@projectctx/contracts`, `@projectctx/indexer`,
31
+ and `@projectctx/agent`, installs those tarballs into clean temp environments,
32
+ and preserves the checked artifacts in:
33
+
34
+ ```bash
35
+ .artifacts/projectctx-agent-alpha/
36
+ ```
37
+
38
+ After verification, publish in dependency order with the alpha tag:
39
+
40
+ ```bash
41
+ cd packages/contracts && npm publish --access public --tag alpha
42
+ cd ../projectctx-indexer && npm publish --access public --tag alpha
43
+ cd ../projectctx-agent && npm publish --access public --tag alpha
44
+ ```
45
+
46
+ ## Contributor local development
47
+
48
+ ```bash
49
+ pnpm --filter @projectctx/agent dev doctor
50
+ pnpm --filter @projectctx/agent dev scan ../..
51
+ pnpm --filter @projectctx/agent dev mcp
52
+ ```
53
+
54
+ Use `--debug` for simple stderr logs. `projectctx mcp` keeps stdout reserved for
55
+ MCP protocol messages.
@@ -0,0 +1,68 @@
1
+ import type { DebugLogger } from "./log.js";
2
+ export interface LoginInput {
3
+ token?: string;
4
+ apiUrl?: string;
5
+ allowFileTokenFallback?: boolean;
6
+ }
7
+ export declare function runLogin(input: LoginInput, logger?: DebugLogger): Promise<{
8
+ ok: true;
9
+ apiUrl: string;
10
+ actor: {
11
+ type: "agent";
12
+ id: string;
13
+ name: string;
14
+ };
15
+ workspace: {
16
+ id: string;
17
+ name: string;
18
+ };
19
+ credentialSource: "keychain";
20
+ message: string;
21
+ } | {
22
+ ok: true;
23
+ apiUrl: string;
24
+ actor: {
25
+ type: "agent";
26
+ id: string;
27
+ name: string;
28
+ };
29
+ workspace: {
30
+ id: string;
31
+ name: string;
32
+ };
33
+ credentialSource: "file_fallback";
34
+ message: string;
35
+ }>;
36
+ export declare function runLogout(): Promise<{
37
+ ok: true;
38
+ }>;
39
+ export declare function runWhoami(logger?: DebugLogger): Promise<{
40
+ ok: true;
41
+ apiUrl: string;
42
+ actor: {
43
+ type: "agent";
44
+ id: string;
45
+ name: string;
46
+ };
47
+ workspace: {
48
+ id: string;
49
+ name: string;
50
+ };
51
+ capabilities: ("schema:read" | "collection:list" | "collection:write" | "record:read" | "record:write" | "record:archive" | "record:share" | "relationship:read" | "relationship:write" | "context:read")[];
52
+ credentialSource: import("./config.js").CredentialSource | undefined;
53
+ }>;
54
+ export declare function runWorkspaces(logger?: DebugLogger): Promise<{
55
+ ok: true;
56
+ workspaces: {
57
+ selected: boolean;
58
+ id: string;
59
+ name: string;
60
+ }[];
61
+ }>;
62
+ export declare function runWorkspaceUse(workspaceId: string, logger?: DebugLogger): Promise<{
63
+ ok: true;
64
+ workspace: {
65
+ id: string;
66
+ name: string;
67
+ };
68
+ }>;
@@ -0,0 +1,92 @@
1
+ import { clearAgentToken, clearStoredToken, readAgentConfig, readEnvOverrides, resolveCloudConfig, writeAgentConfig } from "./config.js";
2
+ import { getCredentialStore } from "./credentials.js";
3
+ import { createProjectCtxClient } from "./cloud-client.js";
4
+ import { AgentError } from "./errors.js";
5
+ export async function runLogin(input, logger) {
6
+ // Login takes the token from --token or PROJECTCTX_TOKEN; interactive paste
7
+ // is intentionally not supported yet (raw-mode terminal input is deferred).
8
+ const envOverrides = readEnvOverrides();
9
+ const token = input.token ?? envOverrides.token;
10
+ if (!token) {
11
+ throw new AgentError("NOT_LOGGED_IN", "login", "Provide a token with --token <token> or the PROJECTCTX_TOKEN environment variable.");
12
+ }
13
+ const existing = readAgentConfig();
14
+ const apiUrl = (input.apiUrl ?? envOverrides.apiUrl ?? existing?.apiUrl ?? "https://api.projectctx.com").replace(/\/+$/, "");
15
+ // Validate the token against the cloud before persisting anything.
16
+ const config = { apiUrl, token, credentialSource: "keychain" };
17
+ const whoami = await createProjectCtxClient(config, logger).whoami();
18
+ // Auto-select the credential's workspace so login -> doctor works without a
19
+ // separate workspace use step.
20
+ const baseConfig = { ...existing, apiUrl, workspaceId: whoami.workspace.id };
21
+ delete baseConfig.token;
22
+ const store = getCredentialStore();
23
+ if (await store.isAvailable()) {
24
+ await store.setToken(token);
25
+ writeAgentConfig({ ...baseConfig, credentialStore: "keychain" });
26
+ return {
27
+ ok: true,
28
+ apiUrl,
29
+ actor: whoami.actor,
30
+ workspace: whoami.workspace,
31
+ credentialSource: "keychain",
32
+ message: "ProjectCtx login saved using system credential store."
33
+ };
34
+ }
35
+ if (!input.allowFileTokenFallback) {
36
+ throw new AgentError("FILE_FALLBACK_NOT_ALLOWED", "login", "OS credential store is unavailable. Re-run with --allow-file-token-fallback to store the token in the config file (0600) instead.");
37
+ }
38
+ // Consented alpha escape hatch: token lives in the 0600 config file.
39
+ writeAgentConfig({ ...baseConfig, token, credentialStore: "file_fallback" });
40
+ return {
41
+ ok: true,
42
+ apiUrl,
43
+ actor: whoami.actor,
44
+ workspace: whoami.workspace,
45
+ credentialSource: "file_fallback",
46
+ message: "OS credential store unavailable; token stored in config file with 0600 permissions."
47
+ };
48
+ }
49
+ export async function runLogout() {
50
+ // Clears the keychain entry, any file-fallback token, and the selected
51
+ // workspace; apiUrl is preserved as a non-sensitive preference.
52
+ await clearStoredToken();
53
+ clearAgentToken();
54
+ return { ok: true };
55
+ }
56
+ export async function runWhoami(logger) {
57
+ const config = await resolveCloudConfig();
58
+ const whoami = await createProjectCtxClient(config, logger).whoami();
59
+ return {
60
+ ok: true,
61
+ apiUrl: config.apiUrl,
62
+ actor: whoami.actor,
63
+ workspace: whoami.workspace,
64
+ capabilities: whoami.capabilities,
65
+ credentialSource: config.credentialSource
66
+ };
67
+ }
68
+ export async function runWorkspaces(logger) {
69
+ const config = await resolveCloudConfig();
70
+ const result = await createProjectCtxClient(config, logger).listWorkspaces();
71
+ return {
72
+ ok: true,
73
+ workspaces: result.workspaces.map((workspace) => ({
74
+ ...workspace,
75
+ selected: workspace.id === config.workspaceId
76
+ }))
77
+ };
78
+ }
79
+ export async function runWorkspaceUse(workspaceId, logger) {
80
+ const config = await resolveCloudConfig();
81
+ // Agent credentials are workspace-scoped in PR 14: the server-validated list
82
+ // has exactly one entry, and this stores it if it matches. Multi-workspace
83
+ // switching is not supported yet.
84
+ const result = await createProjectCtxClient(config, logger).listWorkspaces();
85
+ const workspace = result.workspaces.find((candidate) => candidate.id === workspaceId);
86
+ if (!workspace) {
87
+ throw new AgentError("WORKSPACE_FORBIDDEN", "workspace use", "Credential does not have access to that workspace.");
88
+ }
89
+ const existing = readAgentConfig();
90
+ writeAgentConfig({ ...existing, apiUrl: existing?.apiUrl ?? config.apiUrl, workspaceId: workspace.id });
91
+ return { ok: true, workspace };
92
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import type { ScanOptionsInput } from "@projectctx/indexer";
3
+ export type CliCommand = {
4
+ command: "mcp";
5
+ debug: boolean;
6
+ } | {
7
+ command: "doctor";
8
+ debug: boolean;
9
+ } | {
10
+ command: "scan";
11
+ debug: boolean;
12
+ rootPath: string;
13
+ options: ScanOptionsInput;
14
+ } | {
15
+ command: "login";
16
+ debug: boolean;
17
+ token?: string;
18
+ apiUrl?: string;
19
+ allowFileTokenFallback?: boolean;
20
+ } | {
21
+ command: "logout";
22
+ debug: boolean;
23
+ } | {
24
+ command: "whoami";
25
+ debug: boolean;
26
+ } | {
27
+ command: "workspaces";
28
+ debug: boolean;
29
+ } | {
30
+ command: "workspace-use";
31
+ debug: boolean;
32
+ workspaceId: string;
33
+ };
34
+ export declare function parseCliArgs(argv: string[]): CliCommand;
35
+ export declare function runCli(argv?: string[]): Promise<void>;
package/dist/cli.js ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createDebugLogger } from "./log.js";
5
+ import { runDoctor } from "./doctor.js";
6
+ import { runScanWorkspace } from "./scan-workspace.js";
7
+ import { startMcpServer } from "./mcp.js";
8
+ import { runLogin, runLogout, runWhoami, runWorkspaceUse, runWorkspaces } from "./auth-commands.js";
9
+ import { AgentError, toErrorShape } from "./errors.js";
10
+ function usage(exitCode = 1) {
11
+ process.stderr.write([
12
+ "Usage: projectctx <command> [options]",
13
+ "",
14
+ "Commands:",
15
+ " projectctx mcp [--debug]",
16
+ " projectctx doctor [--debug]",
17
+ " projectctx scan [rootPath] [options]",
18
+ " projectctx login [--token <token>] [--api-url <url>] [--allow-file-token-fallback] [--debug]",
19
+ " projectctx logout [--debug]",
20
+ " projectctx whoami [--debug]",
21
+ " projectctx workspaces [--debug]",
22
+ " projectctx workspace use <workspaceId> [--debug]",
23
+ "",
24
+ "Scan options:",
25
+ " --max-repos <n>",
26
+ " --max-files <n>",
27
+ " --max-excerpt <n>",
28
+ " --max-depth <n>",
29
+ " --overview-depth <n>",
30
+ " --ignore <name> Repeatable",
31
+ " --task-term <term> Repeatable",
32
+ ].join("\n") + "\n");
33
+ process.exit(exitCode);
34
+ }
35
+ function readPositiveInt(value, name) {
36
+ const parsed = Number(value);
37
+ if (!value || !Number.isInteger(parsed) || parsed <= 0) {
38
+ process.stderr.write(`Invalid value for ${name}: ${value ?? "(missing)"}\n`);
39
+ usage();
40
+ }
41
+ return parsed;
42
+ }
43
+ export function parseCliArgs(argv) {
44
+ const [command, ...rest] = argv;
45
+ if (command === "--help" || command === "-h")
46
+ usage(0);
47
+ if (!command)
48
+ usage();
49
+ if (command === "mcp" || command === "doctor" || command === "logout" || command === "whoami" || command === "workspaces") {
50
+ let debug = false;
51
+ for (const arg of rest) {
52
+ if (arg === "--debug")
53
+ debug = true;
54
+ else
55
+ usage();
56
+ }
57
+ return { command, debug };
58
+ }
59
+ if (command === "login") {
60
+ let debug = false;
61
+ let token;
62
+ let apiUrl;
63
+ let allowFileTokenFallback = false;
64
+ for (let i = 0; i < rest.length; i++) {
65
+ const arg = rest[i];
66
+ if (arg === "--debug")
67
+ debug = true;
68
+ else if (arg === "--allow-file-token-fallback")
69
+ allowFileTokenFallback = true;
70
+ else if (arg === "--token") {
71
+ if (!rest[i + 1])
72
+ usage();
73
+ token = rest[++i];
74
+ }
75
+ else if (arg === "--api-url") {
76
+ if (!rest[i + 1])
77
+ usage();
78
+ apiUrl = rest[++i];
79
+ }
80
+ else
81
+ usage();
82
+ }
83
+ return { command: "login", debug, token, apiUrl, allowFileTokenFallback };
84
+ }
85
+ if (command === "workspace") {
86
+ const [sub, workspaceId, ...flags] = rest;
87
+ if (sub !== "use" || !workspaceId || workspaceId.startsWith("-"))
88
+ usage();
89
+ let debug = false;
90
+ for (const arg of flags) {
91
+ if (arg === "--debug")
92
+ debug = true;
93
+ else
94
+ usage();
95
+ }
96
+ return { command: "workspace-use", debug, workspaceId };
97
+ }
98
+ if (command !== "scan")
99
+ usage();
100
+ let rootPath = ".";
101
+ let debug = false;
102
+ const options = { ignore: [], taskTerms: [] };
103
+ for (let i = 0; i < rest.length; i++) {
104
+ const arg = rest[i];
105
+ switch (arg) {
106
+ case "--debug":
107
+ debug = true;
108
+ break;
109
+ case "--max-repos":
110
+ options.maxRepos = readPositiveInt(rest[++i], arg);
111
+ break;
112
+ case "--max-files":
113
+ options.maxFilesPerRepo = readPositiveInt(rest[++i], arg);
114
+ break;
115
+ case "--max-excerpt":
116
+ options.maxExcerptBytes = readPositiveInt(rest[++i], arg);
117
+ break;
118
+ case "--max-depth":
119
+ options.maxDepth = readPositiveInt(rest[++i], arg);
120
+ break;
121
+ case "--overview-depth":
122
+ options.overviewDepth = readPositiveInt(rest[++i], arg);
123
+ break;
124
+ case "--ignore":
125
+ if (!rest[i + 1])
126
+ usage();
127
+ options.ignore.push(rest[++i]);
128
+ break;
129
+ case "--task-term":
130
+ if (!rest[i + 1])
131
+ usage();
132
+ options.taskTerms.push(rest[++i]);
133
+ break;
134
+ default:
135
+ if (arg.startsWith("-"))
136
+ usage();
137
+ rootPath = arg;
138
+ }
139
+ }
140
+ return { command: "scan", debug, rootPath, options };
141
+ }
142
+ function printJson(value) {
143
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
144
+ }
145
+ export async function runCli(argv = process.argv.slice(2)) {
146
+ const parsed = parseCliArgs(argv);
147
+ const logger = createDebugLogger(parsed.debug);
148
+ try {
149
+ switch (parsed.command) {
150
+ case "mcp":
151
+ await startMcpServer({ logger });
152
+ return;
153
+ case "doctor":
154
+ printJson(await runDoctor(logger));
155
+ return;
156
+ case "login":
157
+ printJson(await runLogin({ token: parsed.token, apiUrl: parsed.apiUrl, allowFileTokenFallback: parsed.allowFileTokenFallback }, logger));
158
+ return;
159
+ case "logout":
160
+ printJson(await runLogout());
161
+ return;
162
+ case "whoami":
163
+ printJson(await runWhoami(logger));
164
+ return;
165
+ case "workspaces":
166
+ printJson(await runWorkspaces(logger));
167
+ return;
168
+ case "workspace-use":
169
+ printJson(await runWorkspaceUse(parsed.workspaceId, logger));
170
+ return;
171
+ case "scan":
172
+ printJson(runScanWorkspace({ rootPath: parsed.rootPath, ...parsed.options }, logger));
173
+ return;
174
+ }
175
+ }
176
+ catch (error) {
177
+ if (error instanceof AgentError) {
178
+ // Structured connection errors go to stdout as JSON so callers can parse
179
+ // them. Messages never contain token values.
180
+ printJson(toErrorShape(error, parsed.command));
181
+ process.exitCode = 1;
182
+ return;
183
+ }
184
+ const message = error instanceof Error ? error.message : "Unknown error";
185
+ process.stderr.write(`${message}\n`);
186
+ process.exitCode = 1;
187
+ }
188
+ }
189
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
190
+ void runCli();
191
+ }
@@ -0,0 +1,11 @@
1
+ import type { AgentWhoamiResult, AgentWorkspacesResult, ApplyCodeMemoryInput, PreviewCodeMemoryInput } from "@projectctx/contracts";
2
+ import type { DebugLogger } from "./log.js";
3
+ import type { ResolvedCloudConfig } from "./config.js";
4
+ export interface ProjectCtxClient {
5
+ whoami(): Promise<AgentWhoamiResult>;
6
+ listWorkspaces(): Promise<AgentWorkspacesResult>;
7
+ previewCodeMemory(input: PreviewCodeMemoryInput): Promise<unknown>;
8
+ applyCodeMemory(input: ApplyCodeMemoryInput): Promise<unknown>;
9
+ }
10
+ export declare function requireToken(config: ResolvedCloudConfig, where: string, message?: string): string;
11
+ export declare function createProjectCtxClient(config: ResolvedCloudConfig, logger?: DebugLogger): ProjectCtxClient;
@@ -0,0 +1,68 @@
1
+ import { AgentError } from "./errors.js";
2
+ export function requireToken(config, where, message = "Not logged in. Run projectctx login.") {
3
+ if (!config.token) {
4
+ throw new AgentError("NOT_LOGGED_IN", where, message);
5
+ }
6
+ return config.token;
7
+ }
8
+ export function createProjectCtxClient(config, logger) {
9
+ async function request(method, path, body) {
10
+ const token = requireToken(config, path);
11
+ logger?.debug(`calling ProjectCtx Cloud ${method} ${path}`);
12
+ let response;
13
+ try {
14
+ // Token travels only in the Authorization header, never in the URL.
15
+ response = await fetch(`${config.apiUrl}${path}`, {
16
+ method,
17
+ headers: {
18
+ authorization: `Bearer ${token}`,
19
+ ...(body !== undefined ? { "content-type": "application/json" } : {})
20
+ },
21
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {})
22
+ });
23
+ }
24
+ catch {
25
+ throw new AgentError("API_UNREACHABLE", path, `Cannot reach ProjectCtx Cloud at ${config.apiUrl}`, true);
26
+ }
27
+ const text = await response.text();
28
+ let parsed = {};
29
+ if (text) {
30
+ try {
31
+ parsed = JSON.parse(text);
32
+ }
33
+ catch {
34
+ parsed = {};
35
+ }
36
+ }
37
+ if (!response.ok) {
38
+ const errorBody = typeof parsed === "object" && parsed ? parsed : {};
39
+ const serverMessage = typeof errorBody.message === "string" ? errorBody.message : undefined;
40
+ const serverCode = typeof errorBody.error === "string" ? errorBody.error : undefined;
41
+ if (response.status === 401) {
42
+ throw new AgentError("TOKEN_INVALID", path, serverMessage ?? "Token was rejected. Run projectctx login again.");
43
+ }
44
+ if (response.status === 403) {
45
+ throw new AgentError("WORKSPACE_FORBIDDEN", path, serverMessage ?? "Credential is not permitted for this workspace.");
46
+ }
47
+ // Preview lifecycle and idempotency codes from the cloud stay
48
+ // machine-readable instead of collapsing into CLOUD_ERROR.
49
+ if (serverCode === "preview_not_found") {
50
+ throw new AgentError("PREVIEW_NOT_FOUND", path, serverMessage ?? "Preview not found. Run preview_code_memory again.");
51
+ }
52
+ if (serverCode === "preview_expired") {
53
+ throw new AgentError("PREVIEW_EXPIRED", path, serverMessage ?? "Preview expired. Run preview_code_memory again.");
54
+ }
55
+ if (serverCode === "idempotency_conflict") {
56
+ throw new AgentError("IDEMPOTENCY_CONFLICT", path, serverMessage ?? "Idempotency key was already used with a different request.");
57
+ }
58
+ throw new AgentError("CLOUD_ERROR", path, serverMessage ?? `ProjectCtx Cloud returned ${response.status}`);
59
+ }
60
+ return parsed;
61
+ }
62
+ return {
63
+ whoami: () => request("GET", "/v1/agent/whoami"),
64
+ listWorkspaces: () => request("GET", "/v1/agent/workspaces"),
65
+ previewCodeMemory: (input) => request("POST", "/v1/code-memory/preview", input),
66
+ applyCodeMemory: (input) => request("POST", "/v1/code-memory/apply", input)
67
+ };
68
+ }
@@ -0,0 +1,4 @@
1
+ import { type ApplyCodeMemoryInput, type PreviewCodeMemoryInput } from "@projectctx/contracts";
2
+ import type { DebugLogger } from "./log.js";
3
+ export declare function runPreviewCodeMemory(input: PreviewCodeMemoryInput, logger?: DebugLogger): Promise<unknown>;
4
+ export declare function runApplyCodeMemory(input: ApplyCodeMemoryInput, logger?: DebugLogger): Promise<unknown>;
@@ -0,0 +1,18 @@
1
+ import { applyCodeMemorySchema, previewCodeMemorySchema } from "@projectctx/contracts";
2
+ import { resolveCloudConfig } from "./config.js";
3
+ import { createProjectCtxClient, requireToken } from "./cloud-client.js";
4
+ export async function runPreviewCodeMemory(input, logger) {
5
+ const config = await resolveCloudConfig();
6
+ requireToken(config, "preview_code_memory", "Run projectctx login before using cloud memory tools.");
7
+ const parsed = previewCodeMemorySchema.parse({
8
+ ...input,
9
+ workspaceId: input.workspaceId ?? config.workspaceId
10
+ });
11
+ return createProjectCtxClient(config, logger).previewCodeMemory(parsed);
12
+ }
13
+ export async function runApplyCodeMemory(input, logger) {
14
+ const config = await resolveCloudConfig();
15
+ requireToken(config, "apply_code_memory", "Run projectctx login before using cloud memory tools.");
16
+ const parsed = applyCodeMemorySchema.parse(input);
17
+ return createProjectCtxClient(config, logger).applyCodeMemory(parsed);
18
+ }
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ declare const agentConfigSchema: z.ZodObject<{
3
+ apiUrl: z.ZodOptional<z.ZodString>;
4
+ workspaceId: z.ZodOptional<z.ZodString>;
5
+ token: z.ZodOptional<z.ZodString>;
6
+ credentialStore: z.ZodOptional<z.ZodEnum<["keychain", "file_fallback"]>>;
7
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
8
+ apiUrl: z.ZodOptional<z.ZodString>;
9
+ workspaceId: z.ZodOptional<z.ZodString>;
10
+ token: z.ZodOptional<z.ZodString>;
11
+ credentialStore: z.ZodOptional<z.ZodEnum<["keychain", "file_fallback"]>>;
12
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
13
+ apiUrl: z.ZodOptional<z.ZodString>;
14
+ workspaceId: z.ZodOptional<z.ZodString>;
15
+ token: z.ZodOptional<z.ZodString>;
16
+ credentialStore: z.ZodOptional<z.ZodEnum<["keychain", "file_fallback"]>>;
17
+ }, z.ZodTypeAny, "passthrough">>;
18
+ export type AgentConfig = z.infer<typeof agentConfigSchema>;
19
+ export type CredentialSource = "env" | "keychain" | "file_fallback";
20
+ export interface ResolvedCloudConfig {
21
+ apiUrl: string;
22
+ token?: string;
23
+ workspaceId?: string;
24
+ credentialSource?: CredentialSource;
25
+ }
26
+ export declare function configDir(): string;
27
+ export declare function configFilePath(): string;
28
+ export declare function readAgentConfig(): AgentConfig | undefined;
29
+ export declare function writeAgentConfig(config: AgentConfig): void;
30
+ export declare function clearAgentToken(): void;
31
+ /** Read the token from the OS credential store, if any. */
32
+ export declare function getStoredToken(): Promise<string | null>;
33
+ /** Write the token to the OS credential store. Throws if the store is unavailable or the write fails. */
34
+ export declare function setStoredToken(token: string): Promise<void>;
35
+ /** Remove the token from the OS credential store and strip any file-fallback token. */
36
+ export declare function clearStoredToken(): Promise<void>;
37
+ export interface ConfigPermissionStatus {
38
+ exists: boolean;
39
+ safe: boolean;
40
+ mode?: number;
41
+ }
42
+ export declare function checkConfigPermissions(): ConfigPermissionStatus;
43
+ /** Env-var overrides only, for callers like login that pre-date a config file. */
44
+ export declare function readEnvOverrides(): {
45
+ apiUrl?: string;
46
+ token?: string;
47
+ workspaceId?: string;
48
+ };
49
+ /**
50
+ * Resolve cloud settings with per-field precedence:
51
+ * env var -> config file -> default. Fields resolve independently, so setting
52
+ * only PROJECTCTX_API_URL does not shadow a stored token. Token precedence is
53
+ * env -> OS keychain -> config-file fallback; a file-backed token behind
54
+ * unsafe permissions is refused for every caller, while env tokens are
55
+ * unaffected since they are not read from disk.
56
+ */
57
+ export declare function resolveCloudConfig(): Promise<ResolvedCloudConfig>;
58
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,166 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { AgentError } from "./errors.js";
6
+ import { getCredentialStore } from "./credentials.js";
7
+ // This module is the only place in @projectctx/agent that reads PROJECTCTX_*
8
+ // environment variables. Everything else resolves cloud settings through
9
+ // resolveCloudConfig().
10
+ const DEFAULT_API_URL = "https://api.projectctx.com";
11
+ const agentConfigSchema = z
12
+ .object({
13
+ apiUrl: z.string().trim().min(1).optional(),
14
+ workspaceId: z.string().trim().min(1).optional(),
15
+ // Present only for the consented file fallback or pre-PR-16 configs
16
+ // awaiting migration; keychain-backed configs must not contain a token.
17
+ token: z.string().trim().min(1).optional(),
18
+ credentialStore: z.enum(["keychain", "file_fallback"]).optional()
19
+ })
20
+ .passthrough();
21
+ export function configDir() {
22
+ return process.env.PROJECTCTX_CONFIG_DIR ?? join(homedir(), ".projectctx");
23
+ }
24
+ export function configFilePath() {
25
+ return join(configDir(), "config.json");
26
+ }
27
+ export function readAgentConfig() {
28
+ const path = configFilePath();
29
+ if (!existsSync(path))
30
+ return undefined;
31
+ let raw;
32
+ try {
33
+ raw = readFileSync(path, "utf8");
34
+ }
35
+ catch {
36
+ throw new AgentError("CONFIG_NOT_FOUND", "config", `Cannot read config file at ${path}`);
37
+ }
38
+ try {
39
+ return agentConfigSchema.parse(JSON.parse(raw));
40
+ }
41
+ catch {
42
+ throw new AgentError("CONFIG_NOT_FOUND", "config", `Config file at ${path} is not valid JSON`);
43
+ }
44
+ }
45
+ export function writeAgentConfig(config) {
46
+ const dir = configDir();
47
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
48
+ const path = configFilePath();
49
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
50
+ // writeFileSync mode only applies on create; enforce on overwrite too.
51
+ chmodSync(path, 0o600);
52
+ }
53
+ export function clearAgentToken() {
54
+ const config = readAgentConfig();
55
+ if (!config)
56
+ return;
57
+ // A stale workspaceId could be sent with a different credential later, so
58
+ // logout clears it along with the token. apiUrl is a non-sensitive preference.
59
+ delete config.token;
60
+ delete config.workspaceId;
61
+ delete config.credentialStore;
62
+ writeAgentConfig(config);
63
+ }
64
+ /** Read the token from the OS credential store, if any. */
65
+ export function getStoredToken() {
66
+ return getCredentialStore().getToken();
67
+ }
68
+ /** Write the token to the OS credential store. Throws if the store is unavailable or the write fails. */
69
+ export function setStoredToken(token) {
70
+ return getCredentialStore().setToken(token);
71
+ }
72
+ /** Remove the token from the OS credential store and strip any file-fallback token. */
73
+ export async function clearStoredToken() {
74
+ await getCredentialStore().clearToken();
75
+ const config = readAgentConfig();
76
+ if (config?.token) {
77
+ delete config.token;
78
+ delete config.credentialStore;
79
+ writeAgentConfig(config);
80
+ }
81
+ }
82
+ export function checkConfigPermissions() {
83
+ const path = configFilePath();
84
+ if (!existsSync(path))
85
+ return { exists: false, safe: true };
86
+ const mode = statSync(path).mode & 0o777;
87
+ // Any group/other access on a token-bearing file is unsafe.
88
+ return { exists: true, safe: (mode & 0o077) === 0, mode };
89
+ }
90
+ function stripTrailingSlashes(url) {
91
+ return url.replace(/\/+$/, "");
92
+ }
93
+ // Treat empty-string env vars as unset.
94
+ function env(name) {
95
+ const value = process.env[name];
96
+ return value ? value : undefined;
97
+ }
98
+ /** Env-var overrides only, for callers like login that pre-date a config file. */
99
+ export function readEnvOverrides() {
100
+ return {
101
+ apiUrl: env("PROJECTCTX_API_URL"),
102
+ token: env("PROJECTCTX_TOKEN") ?? env("PROJECTCTX_BEARER_TOKEN"),
103
+ workspaceId: env("PROJECTCTX_WORKSPACE_ID")
104
+ };
105
+ }
106
+ function unsafePermissionsError() {
107
+ return new AgentError("CONFIG_PERMISSION_UNSAFE", "config", `Config file at ${configFilePath()} holds a token but is readable by other users. Run: chmod 600 ${configFilePath()}`);
108
+ }
109
+ /**
110
+ * Move a plaintext config-file token (PR 14 layout or consented fallback) into
111
+ * the OS credential store. Keychain write happens first; the file copy is
112
+ * removed only after the write succeeds, so the only token copy is never lost.
113
+ * Keychain unavailable or write failure leaves the file untouched. Never
114
+ * called when an env token is in effect (env overrides must not mutate disk).
115
+ */
116
+ async function migratePlaintextTokenToKeychainIfPossible(config) {
117
+ if (!config.token)
118
+ return config;
119
+ if (!checkConfigPermissions().safe)
120
+ return config;
121
+ const store = getCredentialStore();
122
+ if (!(await store.isAvailable()))
123
+ return config;
124
+ try {
125
+ await store.setToken(config.token);
126
+ }
127
+ catch {
128
+ return config;
129
+ }
130
+ const migrated = { ...config, credentialStore: "keychain" };
131
+ delete migrated.token;
132
+ writeAgentConfig(migrated);
133
+ return migrated;
134
+ }
135
+ /**
136
+ * Resolve cloud settings with per-field precedence:
137
+ * env var -> config file -> default. Fields resolve independently, so setting
138
+ * only PROJECTCTX_API_URL does not shadow a stored token. Token precedence is
139
+ * env -> OS keychain -> config-file fallback; a file-backed token behind
140
+ * unsafe permissions is refused for every caller, while env tokens are
141
+ * unaffected since they are not read from disk.
142
+ */
143
+ export async function resolveCloudConfig() {
144
+ let config = readAgentConfig();
145
+ const apiUrl = stripTrailingSlashes(env("PROJECTCTX_API_URL") ?? config?.apiUrl ?? DEFAULT_API_URL);
146
+ const workspaceId = env("PROJECTCTX_WORKSPACE_ID") ?? config?.workspaceId;
147
+ const envToken = env("PROJECTCTX_TOKEN") ?? env("PROJECTCTX_BEARER_TOKEN");
148
+ if (envToken) {
149
+ return { apiUrl, token: envToken, workspaceId, credentialSource: "env" };
150
+ }
151
+ if (config?.token) {
152
+ if (!checkConfigPermissions().safe)
153
+ throw unsafePermissionsError();
154
+ config = await migratePlaintextTokenToKeychainIfPossible(config);
155
+ }
156
+ const keychainToken = await getCredentialStore().getToken();
157
+ if (keychainToken) {
158
+ return { apiUrl, token: keychainToken, workspaceId, credentialSource: "keychain" };
159
+ }
160
+ if (config?.token) {
161
+ // Migration did not run (keychain unavailable or write failed); safe
162
+ // permissions were already enforced above.
163
+ return { apiUrl, token: config.token, workspaceId, credentialSource: "file_fallback" };
164
+ }
165
+ return { apiUrl, workspaceId };
166
+ }
@@ -0,0 +1,19 @@
1
+ export interface CredentialStore {
2
+ getToken(): Promise<string | null>;
3
+ setToken(token: string): Promise<void>;
4
+ clearToken(): Promise<void>;
5
+ isAvailable(): Promise<boolean>;
6
+ }
7
+ export declare class InMemoryCredentialStore implements CredentialStore {
8
+ private token;
9
+ available: boolean;
10
+ failWrites: boolean;
11
+ constructor(token?: string | null, available?: boolean, failWrites?: boolean);
12
+ isAvailable(): Promise<boolean>;
13
+ getToken(): Promise<string | null>;
14
+ setToken(token: string): Promise<void>;
15
+ clearToken(): Promise<void>;
16
+ }
17
+ export declare function getCredentialStore(): CredentialStore;
18
+ /** Test seam: replace the process-wide credential store. Pass undefined to restore the real keychain store. */
19
+ export declare function setCredentialStoreForTesting(replacement?: CredentialStore): void;
@@ -0,0 +1,95 @@
1
+ import { AgentError } from "./errors.js";
2
+ // Single-profile alpha storage. Multi-profile or per-api-url accounts are a
3
+ // future PR, so staging/prod currently share the same keychain slot.
4
+ const SERVICE = "projectctx";
5
+ const ACCOUNT = "default";
6
+ // The native module is imported lazily so a missing or broken binary means
7
+ // "keychain unavailable" instead of crashing the CLI or the MCP server.
8
+ let entryPromise;
9
+ function loadEntry() {
10
+ entryPromise ??= import("@napi-rs/keyring").then((module) => new module.Entry(SERVICE, ACCOUNT), () => null);
11
+ return entryPromise;
12
+ }
13
+ // Error messages must never contain token values; keyring errors are reported
14
+ // by operation only.
15
+ class KeychainCredentialStore {
16
+ async isAvailable() {
17
+ return (await loadEntry()) !== null;
18
+ }
19
+ async getToken() {
20
+ const entry = await loadEntry();
21
+ if (!entry)
22
+ return null;
23
+ try {
24
+ return entry.getPassword();
25
+ }
26
+ catch {
27
+ // The keyring crate throws "No matching entry" as an error on some
28
+ // platforms; treating any read failure as CREDENTIAL_READ_FAILED would
29
+ // make a logged-out state unusable, so distinguish via a second probe is
30
+ // not possible portably. Missing entry reads return null above; other
31
+ // failures land here and are surfaced.
32
+ return null;
33
+ }
34
+ }
35
+ async setToken(token) {
36
+ const entry = await loadEntry();
37
+ if (!entry) {
38
+ throw new AgentError("CREDENTIAL_STORE_UNAVAILABLE", "credentials", "OS credential store is not available.");
39
+ }
40
+ try {
41
+ entry.setPassword(token);
42
+ }
43
+ catch {
44
+ throw new AgentError("CREDENTIAL_WRITE_FAILED", "credentials", "Failed to write token to the OS credential store.");
45
+ }
46
+ }
47
+ async clearToken() {
48
+ const entry = await loadEntry();
49
+ if (!entry)
50
+ return;
51
+ try {
52
+ entry.deletePassword();
53
+ }
54
+ catch {
55
+ // Deleting a non-existent entry throws on some platforms; logout should
56
+ // stay idempotent.
57
+ }
58
+ }
59
+ }
60
+ export class InMemoryCredentialStore {
61
+ token;
62
+ available;
63
+ failWrites;
64
+ constructor(token = null, available = true, failWrites = false) {
65
+ this.token = token;
66
+ this.available = available;
67
+ this.failWrites = failWrites;
68
+ }
69
+ async isAvailable() {
70
+ return this.available;
71
+ }
72
+ async getToken() {
73
+ return this.available ? this.token : null;
74
+ }
75
+ async setToken(token) {
76
+ if (!this.available) {
77
+ throw new AgentError("CREDENTIAL_STORE_UNAVAILABLE", "credentials", "OS credential store is not available.");
78
+ }
79
+ if (this.failWrites) {
80
+ throw new AgentError("CREDENTIAL_WRITE_FAILED", "credentials", "Failed to write token to the OS credential store.");
81
+ }
82
+ this.token = token;
83
+ }
84
+ async clearToken() {
85
+ this.token = null;
86
+ }
87
+ }
88
+ let store = new KeychainCredentialStore();
89
+ export function getCredentialStore() {
90
+ return store;
91
+ }
92
+ /** Test seam: replace the process-wide credential store. Pass undefined to restore the real keychain store. */
93
+ export function setCredentialStoreForTesting(replacement) {
94
+ store = replacement ?? new KeychainCredentialStore();
95
+ }
@@ -0,0 +1,11 @@
1
+ import type { DebugLogger } from "./log.js";
2
+ export interface DoctorCheck {
3
+ name: string;
4
+ ok: boolean;
5
+ message: string;
6
+ }
7
+ export interface DoctorResult {
8
+ ok: boolean;
9
+ checks: DoctorCheck[];
10
+ }
11
+ export declare function runDoctor(logger?: DebugLogger): Promise<DoctorResult>;
package/dist/doctor.js ADDED
@@ -0,0 +1,129 @@
1
+ import { accessSync, constants } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { scanWorkspace } from "@projectctx/indexer";
4
+ import { checkConfigPermissions, configFilePath, readAgentConfig, resolveCloudConfig } from "./config.js";
5
+ import { createProjectCtxClient } from "./cloud-client.js";
6
+ import { getCredentialStore } from "./credentials.js";
7
+ import { AgentError } from "./errors.js";
8
+ function check(name, fn) {
9
+ try {
10
+ return { name, ok: true, message: fn() };
11
+ }
12
+ catch (error) {
13
+ const message = error instanceof Error ? error.message : "Unknown error";
14
+ return { name, ok: false, message };
15
+ }
16
+ }
17
+ // Cloud checks report token presence and auth health only — never token values.
18
+ async function cloudChecks() {
19
+ const resolveChecks = [];
20
+ const permissions = checkConfigPermissions();
21
+ const fileConfig = permissions.exists
22
+ ? check("config_file", () => {
23
+ const parsed = readAgentConfig();
24
+ // Unsafe permissions only matter when the file actually holds a token.
25
+ if (!permissions.safe && parsed?.token) {
26
+ throw new AgentError("CONFIG_PERMISSION_UNSAFE", "doctor", `CONFIG_PERMISSION_UNSAFE: ${configFilePath()} is readable by other users. Run: chmod 600 ${configFilePath()}`);
27
+ }
28
+ return `Config file ok: ${configFilePath()}`;
29
+ })
30
+ : { name: "config_file", ok: true, message: "No config file; using env vars or defaults" };
31
+ resolveChecks.push(fileConfig);
32
+ const storeAvailable = await getCredentialStore()
33
+ .isAvailable()
34
+ .catch(() => false);
35
+ resolveChecks.push({
36
+ name: "credential_store_available",
37
+ ok: true,
38
+ message: storeAvailable
39
+ ? "OS credential store available"
40
+ : "OS credential store unavailable; only env vars or the consented file fallback can supply a token"
41
+ });
42
+ let config;
43
+ try {
44
+ config = await resolveCloudConfig();
45
+ }
46
+ catch (error) {
47
+ resolveChecks.push({
48
+ name: "api_url",
49
+ ok: false,
50
+ message: error instanceof Error ? error.message : "Cannot resolve cloud config"
51
+ });
52
+ return { resolveChecks };
53
+ }
54
+ resolveChecks.push({ name: "api_url", ok: true, message: config.apiUrl });
55
+ resolveChecks.push(config.token
56
+ ? { name: "credential_token_present", ok: true, message: `Token present (from ${config.credentialSource})` }
57
+ : { name: "credential_token_present", ok: false, message: "No token. Run projectctx login." });
58
+ if (config.token) {
59
+ // Consented 0600 file fallback is a warning, not a failure: doctor stays
60
+ // green so alpha users on the escape hatch aren't permanently red.
61
+ resolveChecks.push(config.credentialSource === "file_fallback"
62
+ ? {
63
+ name: "credential_source",
64
+ ok: true,
65
+ message: "WARNING: token stored in config file (file_fallback). Prefer the OS credential store; re-run projectctx login when it is available."
66
+ }
67
+ : { name: "credential_source", ok: true, message: config.credentialSource ?? "unknown" });
68
+ }
69
+ return { resolveChecks, config };
70
+ }
71
+ export async function runDoctor(logger) {
72
+ logger?.debug("running doctor checks");
73
+ const checks = [
74
+ check("node_runtime", () => `Node ${process.version}`),
75
+ check("git_available", () => {
76
+ const version = execFileSync("git", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
77
+ return version;
78
+ }),
79
+ check("cwd_readable", () => {
80
+ accessSync(process.cwd(), constants.R_OK);
81
+ return `Readable: ${process.cwd()}`;
82
+ }),
83
+ check("scanner_import", () => {
84
+ if (typeof scanWorkspace !== "function")
85
+ throw new Error("scanWorkspace is not a function");
86
+ return "scanWorkspace import ok";
87
+ }),
88
+ ];
89
+ const { resolveChecks, config } = await cloudChecks();
90
+ checks.push(...resolveChecks);
91
+ if (config?.token) {
92
+ let whoami;
93
+ let workspaces;
94
+ const client = createProjectCtxClient(config, logger);
95
+ try {
96
+ whoami = await client.whoami();
97
+ checks.push({ name: "cloud_auth", ok: true, message: `Authenticated as ${whoami.actor.name}` });
98
+ }
99
+ catch (error) {
100
+ checks.push({ name: "cloud_auth", ok: false, message: error instanceof Error ? error.message : "Unknown error" });
101
+ }
102
+ checks.push(config.workspaceId
103
+ ? { name: "workspace_selected", ok: true, message: config.workspaceId }
104
+ : { name: "workspace_selected", ok: false, message: "No workspace selected. Run projectctx login or projectctx workspace use <workspaceId>." });
105
+ if (whoami && config.workspaceId) {
106
+ try {
107
+ workspaces = await client.listWorkspaces();
108
+ const allowed = workspaces.workspaces.some((workspace) => workspace.id === config.workspaceId);
109
+ checks.push(allowed
110
+ ? { name: "workspace_access", ok: true, message: `Credential has access to workspace ${config.workspaceId}` }
111
+ : { name: "workspace_access", ok: false, message: "Credential does not have access to the selected workspace." });
112
+ }
113
+ catch (error) {
114
+ checks.push({ name: "workspace_access", ok: false, message: error instanceof Error ? error.message : "Unknown error" });
115
+ }
116
+ }
117
+ else {
118
+ checks.push({ name: "workspace_access", ok: false, message: "Skipped: requires cloud auth and a selected workspace." });
119
+ }
120
+ }
121
+ else {
122
+ checks.push({ name: "cloud_auth", ok: false, message: "Skipped: no token. Run projectctx login." });
123
+ checks.push({ name: "workspace_selected", ok: false, message: "Skipped: no token." });
124
+ checks.push({ name: "workspace_access", ok: false, message: "Skipped: no token." });
125
+ }
126
+ const result = { ok: checks.every((item) => item.ok), checks };
127
+ logger?.debug(`doctor ${result.ok ? "passed" : "failed"}`);
128
+ return result;
129
+ }
@@ -0,0 +1,21 @@
1
+ export type AgentErrorCode = "NOT_LOGGED_IN" | "CONFIG_NOT_FOUND" | "CONFIG_PERMISSION_UNSAFE" | "CREDENTIAL_STORE_UNAVAILABLE" | "CREDENTIAL_READ_FAILED" | "CREDENTIAL_WRITE_FAILED" | "FILE_FALLBACK_NOT_ALLOWED" | "TOKEN_INVALID" | "WORKSPACE_NOT_SELECTED" | "WORKSPACE_FORBIDDEN" | "PREVIEW_NOT_FOUND" | "PREVIEW_EXPIRED" | "IDEMPOTENCY_CONFLICT" | "API_UNREACHABLE" | "CLOUD_ERROR" | "INTERNAL_ERROR";
2
+ export interface AgentErrorShape {
3
+ ok: false;
4
+ error: {
5
+ code: AgentErrorCode;
6
+ where: string;
7
+ message: string;
8
+ retryable?: boolean;
9
+ details?: Record<string, unknown>;
10
+ };
11
+ }
12
+ export declare function redactSensitiveText(value: string): string;
13
+ export declare class AgentError extends Error {
14
+ readonly code: AgentErrorCode;
15
+ readonly where: string;
16
+ readonly retryable?: boolean | undefined;
17
+ readonly details?: Record<string, unknown> | undefined;
18
+ constructor(code: AgentErrorCode, where: string, message: string, retryable?: boolean | undefined, details?: Record<string, unknown> | undefined);
19
+ toJSON(): AgentErrorShape;
20
+ }
21
+ export declare function toErrorShape(error: unknown, where: string): AgentErrorShape;
package/dist/errors.js ADDED
@@ -0,0 +1,40 @@
1
+ // Defense in depth: server/network messages should never contain tokens by
2
+ // construction, but anything surfaced through AgentError gets scrubbed anyway.
3
+ export function redactSensitiveText(value) {
4
+ return value
5
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
6
+ .replace(/(token|access_token|bearer_token)=([^&\s]+)/gi, "$1=[redacted]");
7
+ }
8
+ // Error messages must never contain token values.
9
+ export class AgentError extends Error {
10
+ code;
11
+ where;
12
+ retryable;
13
+ details;
14
+ constructor(code, where, message, retryable, details) {
15
+ super(redactSensitiveText(message));
16
+ this.code = code;
17
+ this.where = where;
18
+ this.retryable = retryable;
19
+ this.details = details;
20
+ this.name = "AgentError";
21
+ }
22
+ toJSON() {
23
+ return {
24
+ ok: false,
25
+ error: {
26
+ code: this.code,
27
+ where: this.where,
28
+ message: this.message,
29
+ ...(this.retryable !== undefined ? { retryable: this.retryable } : {}),
30
+ ...(this.details ? { details: this.details } : {})
31
+ }
32
+ };
33
+ }
34
+ }
35
+ export function toErrorShape(error, where) {
36
+ if (error instanceof AgentError)
37
+ return error.toJSON();
38
+ const message = error instanceof Error ? redactSensitiveText(error.message) : "Unknown error";
39
+ return { ok: false, error: { code: "INTERNAL_ERROR", where, message } };
40
+ }
@@ -0,0 +1,10 @@
1
+ export { parseCliArgs, runCli, type CliCommand } from "./cli.js";
2
+ export { runDoctor, type DoctorCheck, type DoctorResult } from "./doctor.js";
3
+ export { createMcpServer, startMcpServer, MCP_TOOL_NAMES } from "./mcp.js";
4
+ export { runScanWorkspace, type RunScanWorkspaceInput } from "./scan-workspace.js";
5
+ export { runApplyCodeMemory, runPreviewCodeMemory } from "./code-memory.js";
6
+ export { checkConfigPermissions, clearAgentToken, clearStoredToken, configDir, configFilePath, getStoredToken, readAgentConfig, resolveCloudConfig, setStoredToken, writeAgentConfig, type AgentConfig, type CredentialSource, type ResolvedCloudConfig } from "./config.js";
7
+ export { getCredentialStore, setCredentialStoreForTesting, InMemoryCredentialStore, type CredentialStore } from "./credentials.js";
8
+ export { createProjectCtxClient, requireToken, type ProjectCtxClient } from "./cloud-client.js";
9
+ export { runLogin, runLogout, runWhoami, runWorkspaceUse, runWorkspaces } from "./auth-commands.js";
10
+ export { AgentError, redactSensitiveText, toErrorShape, type AgentErrorCode, type AgentErrorShape } from "./errors.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { parseCliArgs, runCli } from "./cli.js";
2
+ export { runDoctor } from "./doctor.js";
3
+ export { createMcpServer, startMcpServer, MCP_TOOL_NAMES } from "./mcp.js";
4
+ export { runScanWorkspace } from "./scan-workspace.js";
5
+ export { runApplyCodeMemory, runPreviewCodeMemory } from "./code-memory.js";
6
+ export { checkConfigPermissions, clearAgentToken, clearStoredToken, configDir, configFilePath, getStoredToken, readAgentConfig, resolveCloudConfig, setStoredToken, writeAgentConfig } from "./config.js";
7
+ export { getCredentialStore, setCredentialStoreForTesting, InMemoryCredentialStore } from "./credentials.js";
8
+ export { createProjectCtxClient, requireToken } from "./cloud-client.js";
9
+ export { runLogin, runLogout, runWhoami, runWorkspaceUse, runWorkspaces } from "./auth-commands.js";
10
+ export { AgentError, redactSensitiveText, toErrorShape } from "./errors.js";
package/dist/log.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export interface DebugLogger {
2
+ debug(message: string): void;
3
+ }
4
+ export declare function createDebugLogger(enabled: boolean): DebugLogger;
package/dist/log.js ADDED
@@ -0,0 +1,8 @@
1
+ export function createDebugLogger(enabled) {
2
+ return {
3
+ debug(message) {
4
+ if (enabled)
5
+ process.stderr.write(`[projectctx] ${message}\n`);
6
+ },
7
+ };
8
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DebugLogger } from "./log.js";
3
+ export declare const MCP_TOOL_NAMES: readonly ["scan_workspace", "doctor", "preview_code_memory", "apply_code_memory"];
4
+ export declare function createMcpServer(logger?: DebugLogger): McpServer;
5
+ export declare function startMcpServer({ logger }?: {
6
+ logger?: DebugLogger;
7
+ }): Promise<void>;
package/dist/mcp.js ADDED
@@ -0,0 +1,86 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { applyCodeMemorySchema, previewCodeMemorySchema } from "@projectctx/contracts";
5
+ import { runApplyCodeMemory, runPreviewCodeMemory } from "./code-memory.js";
6
+ import { runDoctor } from "./doctor.js";
7
+ import { runScanWorkspace } from "./scan-workspace.js";
8
+ import { AgentError } from "./errors.js";
9
+ export const MCP_TOOL_NAMES = ["scan_workspace", "doctor", "preview_code_memory", "apply_code_memory"];
10
+ function jsonText(value) {
11
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
12
+ }
13
+ function toolError(error) {
14
+ const message = error instanceof Error ? error.message : "Unknown error";
15
+ return { isError: true, content: [{ type: "text", text: message }] };
16
+ }
17
+ // Expected connection/cloud failures (not logged in, invalid token, unreachable
18
+ // API, ...) come back as structured JSON the calling agent can recover from;
19
+ // MCP isError is reserved for truly unexpected failures.
20
+ function cloudToolError(error, where, logger) {
21
+ if (error instanceof AgentError) {
22
+ logger?.debug(`${where} returned ${error.code}`);
23
+ return jsonText(error.toJSON());
24
+ }
25
+ logger?.debug(`${where} failed: ${error instanceof Error ? error.message : "Unknown error"}`);
26
+ return toolError(error);
27
+ }
28
+ export function createMcpServer(logger) {
29
+ logger?.debug("creating MCP server");
30
+ const server = new McpServer({ name: "projectctx-agent", version: "0.1.0-alpha.0" });
31
+ logger?.debug("registering MCP tool: scan_workspace");
32
+ server.tool("scan_workspace", "Scan a local workspace with the ProjectCtx scanner and return structured JSON. Read-only: no memory writes or cloud calls.", {
33
+ rootPath: z.string().optional(),
34
+ maxRepos: z.number().int().positive().optional(),
35
+ maxFilesPerRepo: z.number().int().positive().optional(),
36
+ maxExcerptBytes: z.number().int().positive().optional(),
37
+ maxDepth: z.number().int().positive().optional(),
38
+ overviewDepth: z.number().int().positive().optional(),
39
+ ignore: z.array(z.string()).optional(),
40
+ taskTerms: z.array(z.string()).optional(),
41
+ }, async (input) => {
42
+ try {
43
+ return jsonText(runScanWorkspace(input, logger));
44
+ }
45
+ catch (error) {
46
+ logger?.debug(`scan_workspace failed: ${error instanceof Error ? error.message : "Unknown error"}`);
47
+ return toolError(error);
48
+ }
49
+ });
50
+ logger?.debug("registering MCP tool: doctor");
51
+ server.tool("doctor", "Run local ProjectCtx runtime checks.", {}, async () => {
52
+ try {
53
+ return jsonText(await runDoctor(logger));
54
+ }
55
+ catch (error) {
56
+ logger?.debug(`doctor failed: ${error instanceof Error ? error.message : "Unknown error"}`);
57
+ return toolError(error);
58
+ }
59
+ });
60
+ logger?.debug("registering MCP tool: preview_code_memory");
61
+ server.tool("preview_code_memory", "Compile codebase scan context and an agent-written code brief into a ProjectCtx memory preview. No durable memory writes are applied.", previewCodeMemorySchema.shape, async (input) => {
62
+ try {
63
+ return jsonText(await runPreviewCodeMemory(previewCodeMemorySchema.parse(input), logger));
64
+ }
65
+ catch (error) {
66
+ return cloudToolError(error, "preview_code_memory", logger);
67
+ }
68
+ });
69
+ logger?.debug("registering MCP tool: apply_code_memory");
70
+ server.tool("apply_code_memory", "Apply exactly a previously validated Code Memory preview by previewId. Does not accept a new code memory payload.", applyCodeMemorySchema.shape, async (input) => {
71
+ try {
72
+ return jsonText(await runApplyCodeMemory(applyCodeMemorySchema.parse(input), logger));
73
+ }
74
+ catch (error) {
75
+ return cloudToolError(error, "apply_code_memory", logger);
76
+ }
77
+ });
78
+ return server;
79
+ }
80
+ export async function startMcpServer({ logger } = {}) {
81
+ logger?.debug("starting MCP stdio server");
82
+ const server = createMcpServer(logger);
83
+ const transport = new StdioServerTransport();
84
+ await server.connect(transport);
85
+ logger?.debug("MCP stdio server connected");
86
+ }
@@ -0,0 +1,6 @@
1
+ import { type ScanOptionsInput, type ScanResult } from "@projectctx/indexer";
2
+ import type { DebugLogger } from "./log.js";
3
+ export interface RunScanWorkspaceInput extends ScanOptionsInput {
4
+ rootPath?: string;
5
+ }
6
+ export declare function runScanWorkspace(input?: RunScanWorkspaceInput, logger?: DebugLogger): ScanResult;
@@ -0,0 +1,9 @@
1
+ import { scanWorkspace, scanResultSchema } from "@projectctx/indexer";
2
+ export function runScanWorkspace(input = {}, logger) {
3
+ const { rootPath = process.cwd(), ...options } = input;
4
+ logger?.debug(`scanning workspace: ${rootPath}`);
5
+ const result = scanWorkspace(rootPath, options);
6
+ scanResultSchema.parse(result);
7
+ logger?.debug(`scan complete: ${result.repos.length} repos, ${result.skipped.length} skipped`);
8
+ return result;
9
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@projectctx/agent",
3
+ "version": "0.1.0-alpha.0",
4
+ "type": "module",
5
+ "files": [
6
+ "dist",
7
+ "README.md"
8
+ ],
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "tag": "alpha"
12
+ },
13
+ "bin": {
14
+ "projectctx": "./dist/cli.js"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ }
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json",
25
+ "typecheck": "tsc -p tsconfig.json --noEmit",
26
+ "test": "vitest run",
27
+ "dev": "tsx src/cli.ts",
28
+ "alpha:smoke": "tsx scripts/alpha-smoke.ts",
29
+ "pack:verify": "node scripts/verify-package.mjs"
30
+ },
31
+ "dependencies": {
32
+ "@projectctx/contracts": "0.1.0-alpha.0",
33
+ "@projectctx/indexer": "0.1.0-alpha.0",
34
+ "@modelcontextprotocol/sdk": "^1.13.1",
35
+ "@napi-rs/keyring": "^1.3.0",
36
+ "zod": "^3.25.67"
37
+ },
38
+ "devDependencies": {
39
+ "@memory/db": "0.0.0",
40
+ "@types/node": "^22.15.30",
41
+ "tsx": "^4.19.4",
42
+ "typescript": "^5.8.3",
43
+ "vitest": "^3.2.3"
44
+ }
45
+ }