@falkenslab/agent-kit 0.30.1 → 0.32.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.
@@ -1,10 +1,8 @@
1
- import { execFile } from "node:child_process";
2
1
  import { createHash } from "node:crypto";
3
2
  import { cp, mkdtemp, readdir, readFile, rm, stat, writeFile, mkdir } from "node:fs/promises";
4
3
  import os from "node:os";
5
4
  import path from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
- import { promisify } from "node:util";
8
6
  import { capabilitiesProblem, parseCapabilities, parseDescriptions, providedBy } from "./extensions.js";
9
7
  import { getLanguage } from "./messages/index.js";
10
8
  import { truncate } from "./toolLabels.js";
@@ -14,6 +12,7 @@ import { extensionSecretsPath, fillOptions, missingOptions, optionsNamed, parseO
14
12
  import { isTrusted } from "./workspaceTrust.js";
15
13
  import { checkRequirements, describeMissing, findOnPath, parseRequirements, requirementsProblem } from "./extensionRequirements.js";
16
14
  import { cachedPackageDir, cachedPackageProblem, cachePackage, npxServer, packageCacheDir, packageSpec } from "./npxPackages.js";
15
+ import { cloneRepository } from "./gitClone.js";
17
16
  /** Whether a server is remote (#61). */
18
17
  export function isRemoteServer(server) {
19
18
  return "url" in server;
@@ -203,7 +202,20 @@ async function pluginServers(dir, raw) {
203
202
  }
204
203
  /** `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}` and `${VAR}` (the agent's variable) in a server's values. */
205
204
  function expand(value, folders) {
206
- return value.replace(/\$\{(\w+)\}/g, (_, name) => name === "CLAUDE_PLUGIN_ROOT" ? folders.root : name === "CLAUDE_PLUGIN_DATA" ? folders.data : (process.env[name] ?? ""));
205
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => (name === "CLAUDE_PLUGIN_ROOT" ? folders.root : name === "CLAUDE_PLUGIN_DATA" ? folders.data : (process.env[name] ?? "")));
206
+ }
207
+ /** The POSIX locale for a session's language: what any program reads to speak the person's language. */
208
+ const LOCALES = { en: "en_US.UTF-8", es: "es_ES.UTF-8", fr: "fr_FR.UTF-8", de: "de_DE.UTF-8" };
209
+ /**
210
+ * A server's locale in the session's language (#86): `LANG` and `LC_MESSAGES`, and `LC_ALL` when the
211
+ * system sets one (it would win). The standard way, so a server asks in the person's language
212
+ * knowing nothing of the kit, and does the same under Claude Code or any other client.
213
+ */
214
+ function localeEnv(language) {
215
+ const locale = language ? LOCALES[language] : undefined;
216
+ if (!locale)
217
+ return {};
218
+ return { LANG: locale, LC_MESSAGES: locale, ...(process.env.LC_ALL ? { LC_ALL: locale } : {}) };
207
219
  }
208
220
  /**
209
221
  * Why one of an extension's servers can't run through the launcher (Node, its script first,
@@ -214,7 +226,8 @@ function serverLaunch(name, server, folders, packages = {}) {
214
226
  const { root } = folders;
215
227
  // Its folders as variables too, as Claude Code gives them to a plugin's servers.
216
228
  const declared = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expand(String(value), folders)]));
217
- const env = { CLAUDE_PLUGIN_ROOT: root, CLAUDE_PLUGIN_DATA: folders.data, ...declared };
229
+ // And the session's language as its locale (#86): a server that reads it can ask and answer in it.
230
+ const env = { CLAUDE_PLUGIN_ROOT: root, CLAUDE_PLUGIN_DATA: folders.data, ...localeEnv(folders.language), ...declared };
218
231
  const npx = npxServer(server);
219
232
  if (typeof npx === "string")
220
233
  return `${npx} (its server "${name}")`;
@@ -390,7 +403,7 @@ function externalExtension(installed, manifest, cache, unavailable = []) {
390
403
  const remote = [];
391
404
  if (Object.keys(manifest.servers).length)
392
405
  await mkdir(installed.dataDir, { recursive: true });
393
- const folders = { root: installed.dir, data: installed.dataDir };
406
+ const folders = { root: installed.dir, data: installed.dataDir, language: getLanguage() };
394
407
  // A remote server's options go into its URL and headers here, when the session opens: the
395
408
  // SDK fixes them then, so a changed value takes opening the session again (#61).
396
409
  const values = Object.values(manifest.servers).some(isRemoteServer) ? await readOptionValues(installed.privateDir, installed.name, manifest.userConfig, installed.lock.options) : {};
@@ -525,7 +538,6 @@ async function problem(installed, cache) {
525
538
  return options;
526
539
  return (await serversProblem(manifest, installed.dir, { cache, locked: installed.lock.packages })) ?? { manifest, unavailable };
527
540
  }
528
- const exec = promisify(execFile);
529
541
  /** Whether `source` names a git repository rather than a folder. */
530
542
  export function isGitSource(source) {
531
543
  return /^(https?:\/\/|git@|ssh:\/\/|git:\/\/|file:\/\/|github:)/.test(source) || /\.git(#.*)?$/.test(source);
@@ -549,12 +561,9 @@ export async function addExtension(source, scopeDir, options = {}) {
549
561
  if (isGitSource(source)) {
550
562
  const [url, ref] = source.replace(/^github:/, "https://github.com/").split("#");
551
563
  clone = await mkdtemp(path.join(os.tmpdir(), "agent-kit-extension-"));
552
- // A pinned commit needs the history to check it out; otherwise the tip is enough.
553
- await exec("git", ["clone", ...(options.sha ? [] : ["--depth", "1"]), ...(ref ? ["--branch", ref] : []), url, clone]);
554
- if (options.sha)
555
- await exec("git", ["-C", clone, "checkout", "--quiet", options.sha]);
556
- commit = (await exec("git", ["-C", clone, "rev-parse", "HEAD"])).stdout.trim();
557
- if (options.sha && commit !== options.sha.toLowerCase())
564
+ // Without the git program (#89): a pinned commit takes the history, otherwise the tip is enough.
565
+ commit = await cloneRepository(url, clone, { ...(ref ? { ref } : {}), ...(options.sha ? { sha: options.sha } : {}) });
566
+ if (options.sha && !commit.startsWith(options.sha.toLowerCase()))
558
567
  throw new Error(`${url} has no commit ${options.sha}.`);
559
568
  from = clone;
560
569
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Clones a git repository into `dir` (#89), without the `git` program: an extension or a
3
+ * marketplace from GitHub installs on a computer without Git, as in a desktop app on a clean
4
+ * Windows. Over HTTP(S) with isomorphic-git (pure JavaScript: Node and Electron); a `file://`
5
+ * repository is read where it is and its files written out, never changed; SSH (`git@…`,
6
+ * `ssh://`, `git://`), which isomorphic-git can't speak, goes to the system's `git` when there is
7
+ * one. `ref` is a branch or a tag (the default branch without it); `sha` pins a commit, which must
8
+ * be on that branch's history. Returns the commit checked out. `dir` keeps no `.git`.
9
+ */
10
+ export declare function cloneRepository(url: string, dir: string, options?: {
11
+ ref?: string;
12
+ sha?: string;
13
+ }): Promise<string>;
@@ -0,0 +1,117 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import git from "isomorphic-git";
8
+ import http from "isomorphic-git/http/node";
9
+ const exec = promisify(execFile);
10
+ /**
11
+ * Clones a git repository into `dir` (#89), without the `git` program: an extension or a
12
+ * marketplace from GitHub installs on a computer without Git, as in a desktop app on a clean
13
+ * Windows. Over HTTP(S) with isomorphic-git (pure JavaScript: Node and Electron); a `file://`
14
+ * repository is read where it is and its files written out, never changed; SSH (`git@…`,
15
+ * `ssh://`, `git://`), which isomorphic-git can't speak, goes to the system's `git` when there is
16
+ * one. `ref` is a branch or a tag (the default branch without it); `sha` pins a commit, which must
17
+ * be on that branch's history. Returns the commit checked out. `dir` keeps no `.git`.
18
+ */
19
+ export async function cloneRepository(url, dir, options = {}) {
20
+ try {
21
+ if (/^(git@|ssh:\/\/|git:\/\/)/.test(url))
22
+ return await cloneWithGitProgram(url, dir, options);
23
+ if (/^https?:\/\//.test(url))
24
+ return await cloneOverHttp(url, dir, options);
25
+ // A local repository: a file:// URL, or a path (one ending in .git, perhaps bare).
26
+ return await exportLocal(url.startsWith("file://") ? fileURLToPath(url) : path.resolve(url), dir, options);
27
+ }
28
+ catch (error) {
29
+ throw new Error(explain(url, options, error), { cause: error });
30
+ }
31
+ }
32
+ /** Over HTTP(S): the tip of a branch or tag in one shallow fetch, or its history when a commit is pinned. */
33
+ async function cloneOverHttp(url, dir, { ref, sha }) {
34
+ await git.clone({ fs, http, dir, url, ...(ref ? { ref } : {}), singleBranch: true, ...(sha ? {} : { depth: 1 }), noTags: !ref });
35
+ if (sha)
36
+ await git.checkout({ fs, dir, ref: await expandCommit(path.join(dir, ".git"), sha), force: true });
37
+ const commit = await git.resolveRef({ fs, dir, ref: "HEAD" });
38
+ await fs.promises.rm(path.join(dir, ".git"), { recursive: true, force: true });
39
+ return commit;
40
+ }
41
+ /** A local repository: the commit's files written into `dir`, the repository itself untouched (no checkout, no index). */
42
+ async function exportLocal(repo, dir, { ref, sha }) {
43
+ // A working copy's .git, or the repository itself when it's bare.
44
+ const gitdir = fs.existsSync(path.join(repo, ".git")) ? path.join(repo, ".git") : repo;
45
+ const commit = sha ? await expandCommit(gitdir, sha) : await git.resolveRef({ fs, gitdir, ref: ref ?? "HEAD" });
46
+ const { commit: { tree } } = await git.readCommit({ fs, gitdir, oid: commit });
47
+ await git.walk({
48
+ fs,
49
+ gitdir,
50
+ trees: [git.TREE({ ref: tree })],
51
+ map: async (filepath, [entry]) => {
52
+ if (!entry || filepath === ".")
53
+ return true;
54
+ const type = await entry.type();
55
+ const target = path.join(dir, filepath);
56
+ if (type === "tree")
57
+ await mkdir(target, { recursive: true });
58
+ else if (type === "blob") {
59
+ await mkdir(path.dirname(target), { recursive: true });
60
+ await writeFile(target, (await entry.content()) ?? new Uint8Array());
61
+ }
62
+ return true;
63
+ },
64
+ });
65
+ return commit;
66
+ }
67
+ /** A commit given whole or abbreviated, as git takes it: the whole id, or an error when there's no such commit. */
68
+ async function expandCommit(gitdir, sha) {
69
+ const oid = sha.length === 40 ? sha.toLowerCase() : await git.expandOid({ fs, gitdir, oid: sha.toLowerCase() });
70
+ await git.readCommit({ fs, gitdir, oid });
71
+ return oid;
72
+ }
73
+ /** SSH, which isomorphic-git can't speak: the system's `git`, or a clear error without one. */
74
+ async function cloneWithGitProgram(url, dir, { ref, sha }) {
75
+ try {
76
+ await exec("git", ["clone", ...(sha ? [] : ["--depth", "1"]), ...(ref ? ["--branch", ref] : []), url, dir]);
77
+ }
78
+ catch (error) {
79
+ if (error.code === "ENOENT")
80
+ throw Object.assign(new Error("no git"), { code: "NoGitForSsh" });
81
+ throw error;
82
+ }
83
+ if (sha)
84
+ await exec("git", ["-C", dir, "checkout", "--quiet", sha]);
85
+ const commit = (await exec("git", ["-C", dir, "rev-parse", "HEAD"])).stdout.trim();
86
+ await fs.promises.rm(path.join(dir, ".git"), { recursive: true, force: true });
87
+ return commit;
88
+ }
89
+ /** What went wrong, for a person: which repository, and whether it's the network, the repository, the ref or the commit. */
90
+ function explain(url, { ref, sha }, error) {
91
+ const code = error.code ?? "";
92
+ const status = error.data?.statusCode;
93
+ let host = url;
94
+ try {
95
+ host = new URL(url).host || url;
96
+ }
97
+ catch {
98
+ // Not a URL (git@host:…): the address as given.
99
+ }
100
+ if (code === "NoGitForSsh")
101
+ return `${url} is an SSH address, which needs the git program, and this computer has none: use its HTTPS address instead.`;
102
+ if (code === "HttpError" && (status === 401 || status === 403 || status === 404))
103
+ return `${url} isn't a repository this computer can reach: it doesn't exist, or it's private.`;
104
+ if (code === "HttpError")
105
+ return `${host} answered with an error (${status ?? "?"}) for ${url}.`;
106
+ if (["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].includes(code))
107
+ return `Couldn't reach ${host} for ${url}: check the network.`;
108
+ if (code === "NotFoundError" || code === "AmbiguousError") {
109
+ if (sha)
110
+ return `${url} has no commit ${sha}${ref ? ` on ${ref}` : ""}.`;
111
+ if (ref)
112
+ return `${url} has no branch or tag ${ref}.`;
113
+ }
114
+ if (code === "NotFoundError" && !/^https?:\/\//.test(url))
115
+ return `${url} isn't a git repository.`;
116
+ return `Couldn't get ${url}${ref ? ` at ${ref}` : ""}: ${error instanceof Error ? error.message : String(error)}`;
117
+ }
@@ -0,0 +1,16 @@
1
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ import { type Language } from "../language.js";
3
+ /**
4
+ * The reply language, said again next to what the model just read (#85): with only the system
5
+ * prompt's line, far from the turn, its narration between tool calls drifted to English after an
6
+ * English result (a source, a skill, the kit's own messages). One short line, about fifteen tokens.
7
+ */
8
+ export declare function languageReminderLine(language: Language): string;
9
+ /**
10
+ * Its hooks: after each of the main agent's tool calls (a subagent has its own prompt line, and
11
+ * its narration isn't the person's), and with the person's message.
12
+ */
13
+ export declare function createLanguageReminder(language: Language): {
14
+ postToolUse: HookCallback;
15
+ userPromptSubmit: HookCallback;
16
+ };
@@ -0,0 +1,24 @@
1
+ import { LANGUAGE_NAMES } from "../language.js";
2
+ /**
3
+ * The reply language, said again next to what the model just read (#85): with only the system
4
+ * prompt's line, far from the turn, its narration between tool calls drifted to English after an
5
+ * English result (a source, a skill, the kit's own messages). One short line, about fifteen tokens.
6
+ */
7
+ export function languageReminderLine(language) {
8
+ return `Reply language: ${LANGUAGE_NAMES[language]}, your narration between tool calls too.`;
9
+ }
10
+ /**
11
+ * Its hooks: after each of the main agent's tool calls (a subagent has its own prompt line, and
12
+ * its narration isn't the person's), and with the person's message.
13
+ */
14
+ export function createLanguageReminder(language) {
15
+ const line = languageReminderLine(language);
16
+ return {
17
+ postToolUse: async (input) => {
18
+ if (input.agent_id)
19
+ return {};
20
+ return { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: line } };
21
+ },
22
+ userPromptSubmit: async () => ({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: line } }),
23
+ };
24
+ }
@@ -1,4 +1,5 @@
1
1
  import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ import { type ToolLabels } from "../toolLabels.js";
2
3
  /**
3
4
  * PreToolUse hook for "interactive" mode: pauses before every action and asks for
4
5
  * confirmation (by keyboard or by file, see humanInput.ts), like reviewing a plan step
@@ -7,5 +8,9 @@ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
7
8
  * `isActive` lets one session switch between "guided" and "interactive" while it runs (see
8
9
  * session.ts's `ModeControl`): the hook stays registered and, while inactive, gives no
9
10
  * decision at all, so the tool call goes on exactly as if the hook weren't there.
11
+ *
12
+ * The panel says the call as the chat does (#88), in the person's language: the session's
13
+ * labels (`labels`: the extensions'), else the kit's for its own tools, then the tool's name and
14
+ * its parameters in one line, cut short; the person approving needn't read JSON.
10
15
  */
11
- export declare function createStepGate(runDir: string, isActive?: () => boolean, selfAskingTools?: readonly string[]): HookCallback;
16
+ export declare function createStepGate(runDir: string, isActive?: () => boolean, selfAskingTools?: readonly string[], labels?: () => ToolLabels | undefined): HookCallback;
@@ -1,5 +1,8 @@
1
1
  import { askForDecision } from "./humanInput.js";
2
2
  import { t } from "../messages/index.js";
3
+ import { createFriendlyToolLabel, withToolLabels } from "../toolLabels.js";
4
+ /** How much of a call's parameters the panel shows, as one line (#88). */
5
+ const MAX_PARAMETERS = 300;
3
6
  const SELF_ASKING_SERVERS = ["mcp__approvals__", "mcp__manualLogin__"];
4
7
  const SELF_ASKING_TOOLS = new Set(["TodoWrite"]);
5
8
  /**
@@ -10,8 +13,13 @@ const SELF_ASKING_TOOLS = new Set(["TodoWrite"]);
10
13
  * `isActive` lets one session switch between "guided" and "interactive" while it runs (see
11
14
  * session.ts's `ModeControl`): the hook stays registered and, while inactive, gives no
12
15
  * decision at all, so the tool call goes on exactly as if the hook weren't there.
16
+ *
17
+ * The panel says the call as the chat does (#88), in the person's language: the session's
18
+ * labels (`labels`: the extensions'), else the kit's for its own tools, then the tool's name and
19
+ * its parameters in one line, cut short; the person approving needn't read JSON.
13
20
  */
14
- export function createStepGate(runDir, isActive = () => true, selfAskingTools = []) {
21
+ export function createStepGate(runDir, isActive = () => true, selfAskingTools = [], labels = () => undefined) {
22
+ const describe = withToolLabels(labels, createFriendlyToolLabel());
15
23
  return async (input) => {
16
24
  if (!isActive())
17
25
  return {};
@@ -20,9 +28,10 @@ export function createStepGate(runDir, isActive = () => true, selfAskingTools =
20
28
  // a source) or only keeps the task list isn't asked about first: that would be asking to ask.
21
29
  if (SELF_ASKING_SERVERS.some((prefix) => pre.tool_name.startsWith(prefix)) || SELF_ASKING_TOOLS.has(pre.tool_name) || selfAskingTools.includes(pre.tool_name))
22
30
  return {};
31
+ const compact = JSON.stringify(pre.tool_input ?? {});
23
32
  const answer = await askForDecision(runDir, {
24
33
  title: t().proposedAction,
25
- lines: [t().toolLine(pre.tool_name), t().parametersLine(JSON.stringify(pre.tool_input, null, 2))],
34
+ lines: [describe(pre.tool_name, pre.tool_input), t().toolLine(pre.tool_name), t().parametersLine(compact.length > MAX_PARAMETERS ? `${compact.slice(0, MAX_PARAMETERS)}…` : compact)],
26
35
  });
27
36
  if (answer === "q") {
28
37
  return {
@@ -9,8 +9,8 @@ export interface TranscriptLogger {
9
9
  /**
10
10
  * Logs every tool call (request + result) as one JSON line, redacting `secrets` (e.g. a
11
11
  * domain-specific password — see `BaseSessionConfig.secrets`) plus
12
- * `CLAUDE_CODE_OAUTH_TOKEN`, which this kit always scrubs regardless of domain since it
13
- * owns the auth-token concept (see claudeAuth.ts).
12
+ * `CLAUDE_CODE_OAUTH_TOKEN` when the person's environment sets one, which this kit always
13
+ * scrubs regardless of domain (it never handles a token itself: see claudeAuth.ts).
14
14
  */
15
15
  export declare function createTranscriptLogger(transcriptPath: string, secrets?: readonly string[]): TranscriptLogger;
16
16
  /** Avoids bloating the transcript with large payloads (browser-tool screenshots/base64). */
@@ -10,8 +10,8 @@ function redact(text, secrets) {
10
10
  /**
11
11
  * Logs every tool call (request + result) as one JSON line, redacting `secrets` (e.g. a
12
12
  * domain-specific password — see `BaseSessionConfig.secrets`) plus
13
- * `CLAUDE_CODE_OAUTH_TOKEN`, which this kit always scrubs regardless of domain since it
14
- * owns the auth-token concept (see claudeAuth.ts).
13
+ * `CLAUDE_CODE_OAUTH_TOKEN` when the person's environment sets one, which this kit always
14
+ * scrubs regardless of domain (it never handles a token itself: see claudeAuth.ts).
15
15
  */
16
16
  export function createTranscriptLogger(transcriptPath, secrets = []) {
17
17
  const allSecrets = [...secrets, process.env.CLAUDE_CODE_OAUTH_TOKEN].filter((s) => Boolean(s));
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Marketplaces (ADR-025, #29): a repository of extensions is a Claude Code marketplace as it is,
3
+ * a folder or git repository with `.claude-plugin/marketplace.json` listing plugins and where
4
+ * each comes from. An agent knows the marketplaces added to it, kept in its extensions folder
5
+ * (`marketplaces.json`, and a copy of each in `.marketplaces/<name>/`), and installs from them
6
+ * by `<plugin>@<marketplace>` into either scope, through `addExtension()`: the same copy, hash and
7
+ * lock as any extension. Its official marketplace is known without asking; any other is added
8
+ * only by hand, after a warning (the command asks to type its name).
9
+ */
1
10
  /** Where a marketplace's plugin comes from, as `marketplace.json` says (Claude Code's forms). */
2
11
  export type PluginSource = string | {
3
12
  source: "github";
@@ -1,20 +1,9 @@
1
- import { execFile } from "node:child_process";
2
1
  import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
5
- import { promisify } from "node:util";
6
4
  import { addExtension, isGitSource } from "./externalExtensions.js";
7
5
  import { fetchArchivePlugin, fetchNpmPlugin } from "./pluginDownloads.js";
8
- /**
9
- * Marketplaces (ADR-025, #29): a repository of extensions is a Claude Code marketplace as it is,
10
- * a folder or git repository with `.claude-plugin/marketplace.json` listing plugins and where
11
- * each comes from. An agent knows the marketplaces added to it, kept in its extensions folder
12
- * (`marketplaces.json`, and a copy of each in `.marketplaces/<name>/`), and installs from them
13
- * by `<plugin>@<marketplace>` into either scope, through `addExtension()`: the same copy, hash and
14
- * lock as any extension. Its official marketplace is known without asking; any other is added
15
- * only by hand, after a warning (the command asks to type its name).
16
- */
17
- const exec = promisify(execFile);
6
+ import { cloneRepository } from "./gitClone.js";
18
7
  const REGISTRY = "marketplaces.json";
19
8
  const COPIES = ".marketplaces";
20
9
  const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -77,9 +66,8 @@ async function fetchMarketplace(source) {
77
66
  try {
78
67
  if (await isGitMarketplace(source)) {
79
68
  const [location, ref] = source.split("#");
80
- await exec("git", ["clone", "--depth", "1", ...(ref ? ["--branch", ref] : []), gitUrl(location), copy]);
81
- const commit = (await exec("git", ["-C", copy, "rev-parse", "HEAD"])).stdout.trim();
82
- await rm(path.join(copy, ".git"), { recursive: true, force: true });
69
+ // Without the git program (#89).
70
+ const commit = await cloneRepository(gitUrl(location), copy, ref ? { ref } : {});
83
71
  return { copy, commit };
84
72
  }
85
73
  await cp(path.resolve(source), copy, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
@@ -194,12 +194,14 @@ export const de = {
194
194
  presentingPlan: "Plan vorlegen",
195
195
  },
196
196
  auth: {
197
- noToken: "Es wurde kein Claude-Authentifizierungstoken gefunden",
198
- noTokenDetail: "(weder die Umgebungsvariable CLAUDE_CODE_OAUTH_TOKEN noch ein im Code übergebenes).",
199
- generateQuestion: 'Jetzt eines mit "claude setup-token" erzeugen? (erfordert ein Claude-Pro/Max-Abonnement)',
200
- cantStart: "Der Agent kann ohne Token nicht starten. Setze CLAUDE_CODE_OAUTH_TOKEN von Hand (oder starte erneut und lass es erzeugen) und versuche es noch einmal.",
201
- generatingHeading: "=== Claude-Authentifizierungstoken wird erzeugt ===",
202
- browserWillOpen: "Zum Anmelden öffnet sich ein Browser; folge den Anweisungen, die der Befehl unten ausgibt.",
203
- generated: "Token für diese Ausführung erzeugt. Speichere es selbst (z. B. CLAUDE_CODE_OAUTH_TOKEN im Profil deiner Shell), um diesen Schritt beim nächsten Mal zu überspringen.",
197
+ notSignedIn: "Du bist nicht bei Claude angemeldet",
198
+ notSignedInDetail: "Melde dich mit deinem Claude-Konto auf Anthropics eigener Seite an: Das übernimmt Claude Code, das mit diesem Agenten kommt, und es speichert die Anmeldung selbst. Oder setze ANTHROPIC_API_KEY, um einen API-Schlüssel zu verwenden.",
199
+ signInQuestion: "Jetzt mit deinem Claude-Konto anmelden?",
200
+ cantStart: "Der Agent kann ohne Claude nicht starten. Starte ihn erneut und melde dich an, oder setze ANTHROPIC_API_KEY.",
201
+ signingInHeading: "=== Anmeldung bei Claude ===",
202
+ browserWillOpen: "Ein Browser öffnet sich mit Anthropics Anmeldeseite. Zeigt die Seite einen Code, füge ihn hier ein.",
203
+ signedIn: "Angemeldet. Claude Code speichert die Anmeldung, du wirst nicht erneut gefragt.",
204
+ notCompleted: "Die Anmeldung wurde nicht abgeschlossen. Starte den Agenten erneut, um es noch einmal zu versuchen, oder setze ANTHROPIC_API_KEY.",
205
+ noClaude: "Das Claude-Code-Programm, das mit dem Agent SDK kommt (@anthropic-ai/claude-agent-sdk-<Plattform>-<Architektur>), ist nicht installiert, daher ist keine Anmeldung möglich. Installiere den Agenten neu, oder setze ANTHROPIC_API_KEY.",
204
206
  },
205
207
  };
@@ -203,13 +203,15 @@ export interface Messages {
203
203
  presentingPlan: string;
204
204
  };
205
205
  auth: {
206
- noToken: string;
207
- noTokenDetail: string;
208
- generateQuestion: string;
206
+ notSignedIn: string;
207
+ notSignedInDetail: string;
208
+ signInQuestion: string;
209
209
  cantStart: string;
210
- generatingHeading: string;
210
+ signingInHeading: string;
211
211
  browserWillOpen: string;
212
- generated: string;
212
+ signedIn: string;
213
+ notCompleted: string;
214
+ noClaude: string;
213
215
  };
214
216
  }
215
217
  export declare const en: Messages;
@@ -193,12 +193,14 @@ export const en = {
193
193
  presentingPlan: "Presenting the plan",
194
194
  },
195
195
  auth: {
196
- noToken: "No Claude authentication token was found",
197
- noTokenDetail: "(neither the CLAUDE_CODE_OAUTH_TOKEN environment variable, nor one passed in).",
198
- generateQuestion: 'Generate one now with "claude setup-token" (requires a Claude Pro/Max subscription)?',
199
- cantStart: "The agent can't start without a token. Set CLAUDE_CODE_OAUTH_TOKEN by hand (or run this again and accept generating it) and try again.",
200
- generatingHeading: "=== Generating a Claude authentication token ===",
201
- browserWillOpen: "A browser will open to log in; follow the instructions the command itself prints below.",
202
- generated: "Token generated for this run. Save it yourself (e.g. CLAUDE_CODE_OAUTH_TOKEN in your shell profile) to skip this next time.",
196
+ notSignedIn: "You aren't signed in to Claude",
197
+ notSignedInDetail: "Sign in with your Claude account on Anthropic's own page: Claude Code, which comes with this agent, does it and keeps the sign-in itself. Or set ANTHROPIC_API_KEY to use an API key.",
198
+ signInQuestion: "Sign in with your Claude account now?",
199
+ cantStart: "The agent can't start without Claude. Run it again and sign in, or set ANTHROPIC_API_KEY.",
200
+ signingInHeading: "=== Signing in to Claude ===",
201
+ browserWillOpen: "A browser will open on Anthropic's sign-in page. If the page shows a code, paste it here.",
202
+ signedIn: "Signed in. Claude Code keeps the sign-in, so this won't be asked again.",
203
+ notCompleted: "The sign-in didn't complete. Run the agent again to retry, or set ANTHROPIC_API_KEY.",
204
+ noClaude: "Claude Code's binary, which comes with the Agent SDK (@anthropic-ai/claude-agent-sdk-<platform>-<arch>), isn't installed, so there's no way to sign in. Reinstall the agent, or set ANTHROPIC_API_KEY.",
203
205
  },
204
206
  };
@@ -194,12 +194,14 @@ export const es = {
194
194
  presentingPlan: "Presentando el plan",
195
195
  },
196
196
  auth: {
197
- noToken: "No se ha encontrado ningún token de autenticación de Claude",
198
- noTokenDetail: "(ni la variable de entorno CLAUDE_CODE_OAUTH_TOKEN ni uno pasado por código).",
199
- generateQuestion: '¿Generar uno ahora con "claude setup-token"? (requiere una suscripción Claude Pro/Max)',
200
- cantStart: "El agente no puede arrancar sin un token. Define CLAUDE_CODE_OAUTH_TOKEN a mano (o vuelve a ejecutarlo y acepta generarlo) e inténtalo de nuevo.",
201
- generatingHeading: "=== Generando un token de autenticación de Claude ===",
202
- browserWillOpen: "Se abrirá un navegador para iniciar sesión; sigue las instrucciones que el propio comando muestra abajo.",
203
- generated: "Token generado para esta ejecución. Guárdalo tú (p. ej. CLAUDE_CODE_OAUTH_TOKEN en el perfil de tu shell) para no repetir esto la próxima vez.",
197
+ notSignedIn: "No has iniciado sesión en Claude",
198
+ notSignedInDetail: "Inicia sesión con tu cuenta de Claude en la propia página de Anthropic: lo hace Claude Code, que viene con este agente, y guarda él mismo la sesión. O define ANTHROPIC_API_KEY para usar una API key.",
199
+ signInQuestion: "¿Iniciar sesión ahora con tu cuenta de Claude?",
200
+ cantStart: "El agente no puede arrancar sin Claude. Vuelve a ejecutarlo e inicia sesión, o define ANTHROPIC_API_KEY.",
201
+ signingInHeading: "=== Iniciando sesión en Claude ===",
202
+ browserWillOpen: "Se abrirá un navegador en la página de inicio de sesión de Anthropic. Si la página muestra un código, pégalo aquí.",
203
+ signedIn: "Sesión iniciada. Claude Code la guarda, así que no se volverá a pedir.",
204
+ notCompleted: "El inicio de sesión no se completó. Vuelve a ejecutar el agente para reintentarlo, o define ANTHROPIC_API_KEY.",
205
+ noClaude: "No está instalado el binario de Claude Code que trae el Agent SDK (@anthropic-ai/claude-agent-sdk-<plataforma>-<arquitectura>), así que no hay cómo iniciar sesión. Reinstala el agente, o define ANTHROPIC_API_KEY.",
204
206
  },
205
207
  };
@@ -194,12 +194,14 @@ export const fr = {
194
194
  presentingPlan: "Présentation du plan",
195
195
  },
196
196
  auth: {
197
- noToken: "Aucun jeton d'authentification Claude n'a été trouvé",
198
- noTokenDetail: "(ni la variable d'environnement CLAUDE_CODE_OAUTH_TOKEN, ni un jeton passé par le code).",
199
- generateQuestion: 'En générer un maintenant avec "claude setup-token" ? (nécessite un abonnement Claude Pro/Max)',
200
- cantStart: "L'agent ne peut pas démarrer sans jeton. Définissez CLAUDE_CODE_OAUTH_TOKEN à la main (ou relancez et acceptez de le générer), puis réessayez.",
201
- generatingHeading: "=== Génération d'un jeton d'authentification Claude ===",
202
- browserWillOpen: "Un navigateur va s'ouvrir pour vous connecter ; suivez les instructions que la commande affiche ci-dessous.",
203
- generated: "Jeton généré pour cette exécution. Enregistrez-le vous-même (par ex. CLAUDE_CODE_OAUTH_TOKEN dans le profil de votre shell) pour éviter cette étape la prochaine fois.",
197
+ notSignedIn: "Tu n'es pas connecté à Claude",
198
+ notSignedInDetail: "Connecte-toi avec ton compte Claude sur la page d'Anthropic : c'est Claude Code, fourni avec cet agent, qui s'en charge et garde lui-même la connexion. Ou définis ANTHROPIC_API_KEY pour utiliser une clé d'API.",
199
+ signInQuestion: "Se connecter maintenant avec ton compte Claude ?",
200
+ cantStart: "L'agent ne peut pas démarrer sans Claude. Relance-le et connecte-toi, ou définis ANTHROPIC_API_KEY.",
201
+ signingInHeading: "=== Connexion à Claude ===",
202
+ browserWillOpen: "Un navigateur va s'ouvrir sur la page de connexion d'Anthropic. Si la page affiche un code, colle-le ici.",
203
+ signedIn: "Connecté. Claude Code garde la connexion : on ne te la redemandera pas.",
204
+ notCompleted: "La connexion n'a pas abouti. Relance l'agent pour réessayer, ou définis ANTHROPIC_API_KEY.",
205
+ noClaude: "Le binaire de Claude Code fourni avec l'Agent SDK (@anthropic-ai/claude-agent-sdk-<plateforme>-<architecture>) n'est pas installé : impossible de se connecter. Réinstalle l'agent, ou définis ANTHROPIC_API_KEY.",
204
206
  },
205
207
  };
@@ -1,5 +1,11 @@
1
1
  /** A path inside `…/app.asar/…`, as the unpacked copy next to it (`…/app.asar.unpacked/…`); any other path as it is. */
2
2
  export declare function outsideArchive(file: string): string;
3
+ /**
4
+ * The module requests that name the SDK's CLI binary on this platform, in the SDK's order:
5
+ * `@anthropic-ai/claude-agent-sdk-<platform>-<arch>/claude[.exe]`, both libcs on Linux (musl
6
+ * first when the system runs on it, as the SDK decides).
7
+ */
8
+ export declare function claudeBinaryRequests(platform?: NodeJS.Platform, arch?: string, musl?: boolean): string[];
3
9
  /**
4
10
  * The SDK's CLI binary unpacked next to the archive, when the kit runs packaged: the SDK would
5
11
  * resolve it inside `app.asar`, where it can't be executed, and its first turn would hang with
@@ -11,6 +11,22 @@ const ARCHIVE = /([\\/])app\.asar([\\/])/;
11
11
  export function outsideArchive(file) {
12
12
  return file.replace(ARCHIVE, "$1app.asar.unpacked$2");
13
13
  }
14
+ /**
15
+ * The module requests that name the SDK's CLI binary on this platform, in the SDK's order:
16
+ * `@anthropic-ai/claude-agent-sdk-<platform>-<arch>/claude[.exe]`, both libcs on Linux (musl
17
+ * first when the system runs on it, as the SDK decides).
18
+ */
19
+ export function claudeBinaryRequests(platform = process.platform, arch = process.arch, musl = platform === "linux" && runsOnMusl()) {
20
+ const packages = platform === "linux" ? (musl ? [`linux-${arch}-musl`, `linux-${arch}`] : [`linux-${arch}`, `linux-${arch}-musl`]) : [`${platform}-${arch}`];
21
+ return packages.map((suffix) => `@anthropic-ai/claude-agent-sdk-${suffix}/claude${platform === "win32" ? ".exe" : ""}`);
22
+ }
23
+ /** A Linux whose C library is musl (Alpine): Node's report names no glibc version there. */
24
+ function runsOnMusl() {
25
+ if (process.platform !== "linux")
26
+ return false;
27
+ const report = process.report?.getReport();
28
+ return Boolean(report?.header) && report?.header?.glibcVersionRuntime === undefined;
29
+ }
14
30
  /**
15
31
  * The SDK's CLI binary unpacked next to the archive, when the kit runs packaged: the SDK would
16
32
  * resolve it inside `app.asar`, where it can't be executed, and its first turn would hang with
@@ -19,11 +35,10 @@ export function outsideArchive(file) {
19
35
  * when not packaged, or when the build didn't unpack it: then the SDK finds its own.
20
36
  */
21
37
  export function unpackedClaudeExecutable(resolve = createRequire(import.meta.url).resolve, platform = process.platform, arch = process.arch) {
22
- const packages = platform === "linux" ? [`linux-${arch}`, `linux-${arch}-musl`] : [`${platform}-${arch}`];
23
- for (const suffix of packages) {
38
+ for (const request of claudeBinaryRequests(platform, arch)) {
24
39
  let file;
25
40
  try {
26
- file = resolve(`@anthropic-ai/claude-agent-sdk-${suffix}/claude${platform === "win32" ? ".exe" : ""}`);
41
+ file = resolve(request);
27
42
  }
28
43
  catch {
29
44
  continue;
@@ -31,6 +31,7 @@ import { createElicitationHandler } from "./elicitation.js";
31
31
  import { createReadOnlyHints, createToolHints } from "./readOnlyHints.js";
32
32
  import { projectServerDuplicates } from "./projectServers.js";
33
33
  import { createSignInReminder, SIGN_IN_RULE } from "./hooks/signInReminder.js";
34
+ import { createLanguageReminder } from "./hooks/languageReminder.js";
34
35
  import { agentKitVersion } from "./version.js";
35
36
  /**
36
37
  * Builds the `options` object passed to the Agent SDK's `query()` — everything about
@@ -58,6 +59,8 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
58
59
  // for the interface too, so the agent answers in the language its interface is in.
59
60
  const language = chooseLanguage(config.language);
60
61
  const replyLine = spec.replyInLanguage === false ? null : replyLanguageInstruction(language);
62
+ // Said again next to what the model reads, so its narration doesn't drift to English (#85).
63
+ const languageReminder = spec.replyInLanguage !== false && spec.replyInLanguage !== "prompt-only" && language !== "en" ? createLanguageReminder(language) : undefined;
61
64
  const withReplyLine = (prompt) => (replyLine ? `${prompt}\n\n${replyLine}` : prompt);
62
65
  const modeControl = createModeControl(mode);
63
66
  // The human approval server only exists outside autonomous mode: the point of
@@ -379,7 +382,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
379
382
  : []),
380
383
  // Registered wherever the approval tool is, and asking only while the current mode is
381
384
  // "interactive": a guided session can switch to interactive and back (ModeControl).
382
- ...(includeApprovalTool ? [{ hooks: [createStepGate(runDir, () => modeControl.mode === "interactive", fromExtensions((contribution) => contribution.selfAskingTools))] }] : []),
385
+ ...(includeApprovalTool ? [{ hooks: [createStepGate(runDir, () => modeControl.mode === "interactive", fromExtensions((contribution) => contribution.selfAskingTools), () => toolLabels)] }] : []),
383
386
  // Likewise for "plan", which any non-autonomous session can switch into: the agent
384
387
  // only reads and plans while it's on (hooks/planGate.ts).
385
388
  ...(includeApprovalTool
@@ -405,12 +408,14 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
405
408
  ]
406
409
  : []),
407
410
  ],
408
- PostToolUse: [{ hooks: [transcriptLogger.postToolUse] }],
411
+ PostToolUse: [{ hooks: [transcriptLogger.postToolUse] }, ...(languageReminder ? [{ hooks: [languageReminder.postToolUse] }] : [])],
409
412
  // The person's message: it ends a batch they approved (#74), and carries the sign-in reminders (#82).
410
- ...(guidedGate || signInExtensions.length
413
+ ...(guidedGate || signInExtensions.length || languageReminder
411
414
  ? {
412
415
  UserPromptSubmit: [
413
416
  ...(guidedGate ? [{ hooks: [guidedGate.userPromptSubmit] }] : []),
417
+ // The reply language, next to the person's message too (#85).
418
+ ...(languageReminder ? [{ hooks: [languageReminder.userPromptSubmit] }] : []),
414
419
  // With the person's message, which extensions now wait for a sign-in (#82).
415
420
  ...(signInExtensions.length ? [{ hooks: [createSignInReminder({ extensions: () => signInExtensions, status: () => late.view?.controls().mcpServerStatus })] }] : []),
416
421
  ],
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { setTheme, getTheme, DEFAULT_THEME, type Theme, type ThemeColor } from "
3
3
  export { isExitPromptError } from "./tui/promptErrors.js";
4
4
  export { allowAnyMcpTool } from "./core/mcpPermissions.js";
5
5
  export { createPromptLoader } from "./core/promptTemplate.js";
6
- export { resolveClaudeAuth, type ClaudeAuthConfig } from "./core/claudeAuth.js";
6
+ export { claudeExecutable, claudeSignedIn, startClaudeSignIn, type ClaudeAuthOptions, type ClaudeSignIn } from "./core/claudeAuth.js";
7
7
  export { ensureClaudeAuth } from "./tui/claudeAuth.js";
8
8
  export { createFriendlyToolLabel, truncate, truncatePath, withToolLabels, withToolPhrases, withToolResults, quietResults, type ToolResult, type ToolDescriber, type ToolLabel, type ToolLabels, } from "./core/toolLabels.js";
9
9
  export type { Mode, BaseSessionConfig, AgentSpec, AgentIdentity, PlanModeSpec, ExtensionGateSpec } from "./core/agentSpec.js";