@craftsamo/git-tools 0.1.0 → 0.2.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,6 +1,6 @@
1
1
  {
2
2
  "name": "git-tools",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Git tools for coding agents: state, commits, rewrites and stacked branches, worktrees and pushing.",
5
5
  "author": {
6
6
  "name": "craftsamo",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-tools",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Git tools for coding agents: state, commits, rewrites and stacked branches, worktrees and pushing.",
5
5
  "author": {
6
6
  "name": "craftsamo",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-tools",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Git tools for coding agents: state, commits, rewrites and stacked branches, worktrees and pushing.",
5
5
  "author": {
6
6
  "name": "craftsamo",
@@ -15,6 +15,7 @@
15
15
  "command": "node",
16
16
  "args": ["dist/mcp.js"],
17
17
  "cwd": ".",
18
+ "default_tools_approval_mode": "writes",
18
19
  "env_vars": [
19
20
  "GIT_TOOLS_TOOLSETS",
20
21
  "GIT_TOOLS_READ_ONLY",
package/README.md CHANGED
@@ -96,7 +96,15 @@ codex plugin add git-tools@craft-agent
96
96
 
97
97
  Codex turns `-` into `_` in server names, so it shows the tools as
98
98
  `mcp__git_tools__git_*`. It starts the MCP server in the plugin's
99
- install directory, which is never the caller's working tree.
99
+ install directory and passes no roots, so the server takes each call's directory
100
+ from what Codex sends with the call: its sandbox state, which the server asks
101
+ for, else the one workspace it names.
102
+
103
+ The plugin declares `default_tools_approval_mode = "writes"`: Codex asks before
104
+ every tool not marked read-only. A user's
105
+ `[plugins."<plugin>".mcp_servers.<server>]` setting can only tighten it. Setting
106
+ `auto` there yields `prompt` (reads asked too), since Codex treats `auto` and
107
+ `writes` as incomparable.
100
108
 
101
109
  ### Grok Build
102
110
 
@@ -244,8 +252,10 @@ The toolset is opt-in. The `git-push` skill says how they fit together.
244
252
  the entry points do. A tool that runs a program is tested against a fake of
245
253
  that program on `PATH`; no test reaches a real service.
246
254
  3. List it in `src/tools.ts`. A new toolset goes into `toolsets` in
247
- `src/project.ts` (and `defaultToolsets` when it is on by default), and a
248
- variable the tool reads besides the settings into `toolVariables`.
255
+ `src/project.ts` (and `defaultToolsets` when it is on by default), a
256
+ setting of the package's own into `options` (and `OPTIONS` in `project.py`
257
+ with `config_schema` in `plugin.yaml`), and a variable the tool reads
258
+ besides the settings into `toolVariables`.
249
259
  4. Add or update the toolset's skill, `skills/<prefix>-<toolset>/SKILL.md`,
250
260
  whose frontmatter names the toolset under `metadata`.
251
261
  5. Run `pnpm run build && pnpm run manifests` to list the tool in
@@ -272,8 +282,11 @@ Where a write is asked about, and where it is not:
272
282
 
273
283
  - **OpenCode** grants any action no rule names, so a write runs unasked unless
274
284
  a rule asks for it: add the `git_*` rule shown under [OpenCode](#opencode).
275
- - **Codex** under `codex exec`, and **Grok Build** under `grok -p`, refuse a
276
- write they cannot ask about, unless it is allowed beforehand.
285
+ - **Codex** asks before every write because of the plugin's approval mode.
286
+ Under `codex exec` it refuses with "MCP tool call requires approval, but
287
+ approval policy is never".
288
+ - **Grok Build** under `grok -p` refuses a write it cannot ask about, unless it
289
+ is allowed beforehand.
277
290
  - **Grok Build** calls the plugin's tools through its `use_tool` tool, and the
278
291
  agent often names a tool without its server first. Grok checks the rules
279
292
  against the name as given, so the server-qualified rule alone does not match
@@ -298,15 +311,17 @@ variable, and an option or setting wins over the variable too.
298
311
  | `exclude_tools` | `--exclude-tools` | `GIT_TOOLS_EXCLUDE_TOOLS` | single tools to remove by ID; wins over `tools` and `toolsets` |
299
312
  | `confirm` | `--confirm` | `GIT_TOOLS_CONFIRM` | MCP server only: `harness` (default) or `elicitation`, as the conventions say |
300
313
  | `language` | `--language` | `GIT_TOOLS_LANGUAGE` | native plugins only: the approval prompt's language, such as `ja` |
314
+ | `emoji` | — | — | Hermes only: the emoji shown beside each toolset, by toolset name (`""` for none) |
301
315
  | `published_text_rules` | `--published-text-rules` | `GIT_TOOLS_PUBLISHED_TEXT_RULES` | the absolute path of a JSON file of published-text rules (see the push toolset); unset, nothing is checked |
302
316
  | `worktree_root` | `--worktree-root` | `GIT_TOOLS_WORKTREE_ROOT` | the absolute path where task worktrees go (`~` allowed); unset, `~/worktrees` |
303
317
 
304
- Lists are comma-separated in a flag or a variable. `published_text_rules` and
305
- `worktree_root` hold text, trimmed and blank meaning unset, and are read by the
306
- tools themselves; the plugin options and Hermes settings name them the same
307
- way. The layout is `<root>/<repository>/<branch>`, with each `/` of the branch
308
- written `-`; the repository is the last part of `origin`'s URL without `.git`,
309
- else the folder name of the main checkout. Where each harness reads them:
318
+ Lists are comma-separated in a flag or a variable. A package's own settings,
319
+ listed in `options` in `src/project.ts`, take a row each here and are read the
320
+ same way: `published_text_rules` and `worktree_root` hold text, trimmed and
321
+ blank meaning unset, and the tools read them themselves. The layout is
322
+ `<root>/<repository>/<branch>`, with each `/` of the branch written `-`; the
323
+ repository is the last part of `origin`'s URL without `.git`, else the folder
324
+ name of the main checkout. Where each harness reads them:
310
325
 
311
326
  - **OpenCode** takes the plugin `options` shown above, with lists as arrays and
312
327
  `read_only` as a boolean, and falls back to the variables. V2 runs plugins in
@@ -358,7 +373,7 @@ else the folder name of the main checkout. Where each harness reads them:
358
373
  ## Layout
359
374
 
360
375
  - `src/project.ts` — the package's name, tool ID prefix, setting prefix,
361
- toolsets and the other variables its tools read.
376
+ toolsets, tool options and the other variables its tools read.
362
377
  - `src/core/` — the tool contract and the tools, free of any harness and of
363
378
  the project's names.
364
379
  - `src/tools.ts` — the list of every tool, which each entry point registers.
package/dist/cli.js CHANGED
@@ -26,8 +26,8 @@ var PROJECT = {
26
26
  toolsets: ["repo", "commit", "rebase", "worktree", "push"],
27
27
  /** The toolsets on when a setting names none. */
28
28
  defaultToolsets: ["repo", "commit", "rebase"],
29
- /** The settings the tools themselves read, beside the ones every entry point takes; each holds text and is named in snake_case. */
30
- options: ["published_text_rules", "worktree_root"],
29
+ /** The package's own settings, which the tools themselves read: each snake_case name and its type, with bounds when it needs them. */
30
+ options: { published_text_rules: "text", worktree_root: "text" },
31
31
  /** The environment variables the tools read besides the settings, which Codex forwards to the server after them. */
32
32
  toolVariables: [
33
33
  "LANGUAGE",
@@ -22994,7 +22994,8 @@ function compileRules(path, entries) {
22994
22994
  });
22995
22995
  }
22996
22996
  async function loadPublishedTextRules(options, signal) {
22997
- const path = options?.[PUBLISHED_TEXT_RULES_OPTION]?.trim();
22997
+ const option = options?.[PUBLISHED_TEXT_RULES_OPTION];
22998
+ const path = typeof option === "string" ? option.trim() : void 0;
22998
22999
  if (!path) return [];
22999
23000
  if (!isAbsolute4(path)) throw invalidRules(path, "the value must be an absolute path");
23000
23001
  return compileRules(path, parseRulesJson(path, await readRulesFile(path, signal)));
@@ -25349,7 +25350,8 @@ function expandHome(value) {
25349
25350
  return value.startsWith(HOME_PREFIX) ? join8(homeDirectory(), value.slice(HOME_PREFIX.length)) : value;
25350
25351
  }
25351
25352
  function worktreeRoot(ctx) {
25352
- const value = ctx.options?.[WORKTREE_ROOT_OPTION]?.trim();
25353
+ const option = ctx.options?.[WORKTREE_ROOT_OPTION];
25354
+ const value = typeof option === "string" ? option.trim() : void 0;
25353
25355
  if (!value) return join8(homeDirectory(), DEFAULT_ROOT_FOLDER);
25354
25356
  const expanded = expandHome(value);
25355
25357
  if (!isAbsolute5(expanded)) {
@@ -26440,6 +26442,8 @@ var PROJECT_PROFILE = {
26440
26442
  };
26441
26443
 
26442
26444
  // src/entry/settings.ts
26445
+ import { homedir as homedir2 } from "node:os";
26446
+ import { isAbsolute as isAbsolute6, join as join11, resolve as resolvePath, sep as sep3 } from "node:path";
26443
26447
  var CONFIRM_MODES = ["harness", "elicitation"];
26444
26448
  var ALL_TOOLSETS = "all";
26445
26449
  var TRUE_VALUES = ["1", "true"];
@@ -26451,10 +26455,17 @@ var OPTIONS = {
26451
26455
  exclude_tools: { flag: "--exclude-tools", envSuffix: "EXCLUDE_TOOLS" },
26452
26456
  confirm: { flag: "--confirm", envSuffix: "CONFIRM" }
26453
26457
  };
26458
+ function profileOptions(profile) {
26459
+ return profile.options ?? {};
26460
+ }
26454
26461
  function settingOptions(profile) {
26455
- const toolOptions = profile.options.map((name) => [
26462
+ const toolOptions = Object.entries(profileOptions(profile)).map(([name, spec]) => [
26456
26463
  name,
26457
- { flag: `--${name.replace(/_/g, "-")}`, envSuffix: name.toUpperCase() }
26464
+ {
26465
+ flag: `--${name.replace(/_/g, "-")}`,
26466
+ envSuffix: name.toUpperCase(),
26467
+ ...optionType(spec) === "boolean" && { boolean: true }
26468
+ }
26458
26469
  ]);
26459
26470
  return { ...OPTIONS, ...Object.fromEntries(toolOptions) };
26460
26471
  }
@@ -26462,8 +26473,9 @@ function envName(name, profile) {
26462
26473
  return `${profile.envPrefix}_${settingOptions(profile)[name].envSuffix}`;
26463
26474
  }
26464
26475
  function readFlags(argv, profile) {
26465
- const options = settingOptions(profile);
26466
- const byFlag = new Map(Object.entries(options).map(([name, option]) => [option.flag, { name, ...option }]));
26476
+ const byFlag = new Map(
26477
+ Object.entries(settingOptions(profile)).map(([name, option]) => [option.flag, { name, ...option }])
26478
+ );
26467
26479
  const given = {};
26468
26480
  for (let i = 0; i < argv.length; i++) {
26469
26481
  const arg = argv[i];
@@ -26515,19 +26527,103 @@ function parseConfirm(value, source) {
26515
26527
  }
26516
26528
  return mode;
26517
26529
  }
26530
+ function parseNumber(value, source) {
26531
+ const trimmed2 = value.trim();
26532
+ const parsed = trimmed2 === "" ? Number.NaN : Number(trimmed2);
26533
+ if (!Number.isFinite(parsed)) {
26534
+ throw new ToolError("invalid_argument", `Unknown number "${value}" in ${source}; use a finite number.`);
26535
+ }
26536
+ return parsed;
26537
+ }
26538
+ function absolutePath(text, source, env) {
26539
+ const home = env.HOME !== void 0 && isAbsolute6(env.HOME) ? env.HOME : homedir2();
26540
+ const expanded = text === "~" || text.startsWith("~/") || text.startsWith(`~${sep3}`) ? join11(home, text.slice(1)) : text;
26541
+ if (!isAbsolute6(expanded)) throw new ToolError("invalid_argument", `${source} must be an absolute path.`);
26542
+ return resolvePath(expanded);
26543
+ }
26544
+ function absolutePaths(items, source, env) {
26545
+ return items.length > 0 ? items.map((item) => absolutePath(item, source, env)) : void 0;
26546
+ }
26518
26547
  function envText(name, env, profile) {
26519
26548
  const variable = envName(name, profile);
26520
26549
  const text = env[variable];
26521
26550
  return text === void 0 || text.trim() === "" ? void 0 : { text, source: variable };
26522
26551
  }
26552
+ function optionType(spec) {
26553
+ return typeof spec === "string" ? spec : spec.type;
26554
+ }
26555
+ function itemCount(count) {
26556
+ return count === 1 ? "1 item" : `${count} items`;
26557
+ }
26558
+ function numberBoundsError({ integer: integer2, min, max }, source) {
26559
+ const range = min !== void 0 && max !== void 0 ? ` from ${min} to ${max}` : min !== void 0 ? ` of at least ${min}` : max !== void 0 ? ` of at most ${max}` : "";
26560
+ return new ToolError("invalid_argument", `${source} must be ${integer2 ? "a whole number" : "a number"}${range}.`);
26561
+ }
26562
+ function countBoundsError({ min, max }, source) {
26563
+ const range = min !== void 0 && max !== void 0 ? `from ${min} to ${max} items` : min !== void 0 ? `at least ${itemCount(min)}` : `at most ${itemCount(max)}`;
26564
+ return new ToolError("invalid_argument", `${source} must list ${range}.`);
26565
+ }
26566
+ function withinBounds(spec, value, source) {
26567
+ if (value === void 0 || typeof spec === "string") return value;
26568
+ const bounds = spec;
26569
+ const { integer: integer2, min, max } = bounds;
26570
+ if (typeof value === "number") {
26571
+ if (integer2 && !Number.isInteger(value) || min !== void 0 && value < min || max !== void 0 && value > max) {
26572
+ throw numberBoundsError(bounds, source);
26573
+ }
26574
+ } else if (typeof value === "object" && (min !== void 0 && value.length < min || max !== void 0 && value.length > max)) {
26575
+ throw countBoundsError(bounds, source);
26576
+ }
26577
+ return value;
26578
+ }
26579
+ function parseOptionText(spec, given, env) {
26580
+ return withinBounds(spec, parseTypedText(optionType(spec), given, env), given.source);
26581
+ }
26582
+ function parseTypedText(kind, given, env) {
26583
+ switch (kind) {
26584
+ case "text":
26585
+ return nonBlank(given.text);
26586
+ case "boolean":
26587
+ return parseBoolean(given.text, given.source);
26588
+ case "number":
26589
+ return parseNumber(given.text, given.source);
26590
+ case "list": {
26591
+ const items = splitList(given.text);
26592
+ return items.length > 0 ? items : void 0;
26593
+ }
26594
+ case "path": {
26595
+ const text = nonBlank(given.text);
26596
+ return text === void 0 ? void 0 : absolutePath(text, given.source, env);
26597
+ }
26598
+ case "paths":
26599
+ return absolutePaths(splitList(given.text), given.source, env);
26600
+ }
26601
+ }
26523
26602
  function optionsFrom(flags, env, profile) {
26524
26603
  const options = {};
26525
- for (const name of profile.options) {
26526
- const text = nonBlank(flags[name] ?? envText(name, env, profile)?.text);
26527
- if (text !== void 0) options[name] = text;
26604
+ for (const [name, spec] of Object.entries(profileOptions(profile))) {
26605
+ if (flags[name] !== void 0) {
26606
+ const parsed = parseOptionText(spec, { text: flags[name], source: settingOptions(profile)[name].flag }, env);
26607
+ if (parsed !== void 0) options[name] = parsed;
26608
+ continue;
26609
+ }
26610
+ const fromEnv = envText(name, env, profile);
26611
+ if (fromEnv !== void 0) {
26612
+ const parsed = parseOptionText(spec, fromEnv, env);
26613
+ if (parsed !== void 0) options[name] = parsed;
26614
+ }
26528
26615
  }
26529
26616
  return options;
26530
26617
  }
26618
+ function parseToolOptions(argv, env, profile = PROJECT_PROFILE) {
26619
+ const flags = readFlags(argv, profile);
26620
+ const known = profileOptions(profile);
26621
+ const other = Object.keys(flags).find((name) => !(name in known));
26622
+ if (other !== void 0) {
26623
+ throw new ToolError("invalid_argument", `Unknown option "${settingOptions(profile)[other].flag}".`);
26624
+ }
26625
+ return optionsFrom(flags, env, profile);
26626
+ }
26531
26627
  function parseSettings(argv, env, profile = PROJECT_PROFILE) {
26532
26628
  const flags = readFlags(argv, profile);
26533
26629
  const value = (name) => flags[name] !== void 0 ? { text: flags[name], source: OPTIONS[name].flag } : envText(name, env, profile);
@@ -26556,14 +26652,6 @@ function parseToolSettings(argv, env, profile = PROJECT_PROFILE) {
26556
26652
  );
26557
26653
  return settings;
26558
26654
  }
26559
- function parseToolOptions(argv, env, profile = PROJECT_PROFILE) {
26560
- const flags = readFlags(argv, profile);
26561
- const other = Object.keys(flags).find((name) => !profile.options.includes(name));
26562
- if (other !== void 0) {
26563
- throw new ToolError("invalid_argument", `Unknown option "${settingOptions(profile)[other].flag}".`);
26564
- }
26565
- return optionsFrom(flags, env, profile);
26566
- }
26567
26655
  function languageVariable(profile = PROJECT_PROFILE) {
26568
26656
  return `${profile.envPrefix}_LANGUAGE`;
26569
26657
  }
package/dist/mcp.js CHANGED
@@ -29094,6 +29094,10 @@ function packageRoot(bundleUrl) {
29094
29094
  return dirname2(dirname2(fileURLToPath(bundleUrl)));
29095
29095
  }
29096
29096
 
29097
+ // src/entry/settings.ts
29098
+ import { homedir } from "node:os";
29099
+ import { isAbsolute as isAbsolute2, join as join2, resolve as resolvePath, sep as sep2 } from "node:path";
29100
+
29097
29101
  // src/project.ts
29098
29102
  var PROJECT = {
29099
29103
  /** The plugin, MCP server and program name. */
@@ -29108,8 +29112,8 @@ var PROJECT = {
29108
29112
  toolsets: ["repo", "commit", "rebase", "worktree", "push"],
29109
29113
  /** The toolsets on when a setting names none. */
29110
29114
  defaultToolsets: ["repo", "commit", "rebase"],
29111
- /** The settings the tools themselves read, beside the ones every entry point takes; each holds text and is named in snake_case. */
29112
- options: ["published_text_rules", "worktree_root"],
29115
+ /** The package's own settings, which the tools themselves read: each snake_case name and its type, with bounds when it needs them. */
29116
+ options: { published_text_rules: "text", worktree_root: "text" },
29113
29117
  /** The environment variables the tools read besides the settings, which Codex forwards to the server after them. */
29114
29118
  toolVariables: [
29115
29119
  "LANGUAGE",
@@ -29169,10 +29173,17 @@ var OPTIONS = {
29169
29173
  exclude_tools: { flag: "--exclude-tools", envSuffix: "EXCLUDE_TOOLS" },
29170
29174
  confirm: { flag: "--confirm", envSuffix: "CONFIRM" }
29171
29175
  };
29176
+ function profileOptions(profile) {
29177
+ return profile.options ?? {};
29178
+ }
29172
29179
  function settingOptions(profile) {
29173
- const toolOptions = profile.options.map((name) => [
29180
+ const toolOptions = Object.entries(profileOptions(profile)).map(([name, spec]) => [
29174
29181
  name,
29175
- { flag: `--${name.replace(/_/g, "-")}`, envSuffix: name.toUpperCase() }
29182
+ {
29183
+ flag: `--${name.replace(/_/g, "-")}`,
29184
+ envSuffix: name.toUpperCase(),
29185
+ ...optionType(spec) === "boolean" && { boolean: true }
29186
+ }
29176
29187
  ]);
29177
29188
  return { ...OPTIONS, ...Object.fromEntries(toolOptions) };
29178
29189
  }
@@ -29180,8 +29191,9 @@ function envName(name, profile) {
29180
29191
  return `${profile.envPrefix}_${settingOptions(profile)[name].envSuffix}`;
29181
29192
  }
29182
29193
  function readFlags(argv, profile) {
29183
- const options = settingOptions(profile);
29184
- const byFlag = new Map(Object.entries(options).map(([name, option]) => [option.flag, { name, ...option }]));
29194
+ const byFlag = new Map(
29195
+ Object.entries(settingOptions(profile)).map(([name, option]) => [option.flag, { name, ...option }])
29196
+ );
29185
29197
  const given = {};
29186
29198
  for (let i = 0; i < argv.length; i++) {
29187
29199
  const arg = argv[i];
@@ -29233,16 +29245,91 @@ function parseConfirm(value, source) {
29233
29245
  }
29234
29246
  return mode;
29235
29247
  }
29248
+ function parseNumber(value, source) {
29249
+ const trimmed2 = value.trim();
29250
+ const parsed = trimmed2 === "" ? Number.NaN : Number(trimmed2);
29251
+ if (!Number.isFinite(parsed)) {
29252
+ throw new ToolError("invalid_argument", `Unknown number "${value}" in ${source}; use a finite number.`);
29253
+ }
29254
+ return parsed;
29255
+ }
29256
+ function absolutePath(text, source, env) {
29257
+ const home = env.HOME !== void 0 && isAbsolute2(env.HOME) ? env.HOME : homedir();
29258
+ const expanded = text === "~" || text.startsWith("~/") || text.startsWith(`~${sep2}`) ? join2(home, text.slice(1)) : text;
29259
+ if (!isAbsolute2(expanded)) throw new ToolError("invalid_argument", `${source} must be an absolute path.`);
29260
+ return resolvePath(expanded);
29261
+ }
29262
+ function absolutePaths(items, source, env) {
29263
+ return items.length > 0 ? items.map((item) => absolutePath(item, source, env)) : void 0;
29264
+ }
29236
29265
  function envText(name, env, profile) {
29237
29266
  const variable = envName(name, profile);
29238
29267
  const text = env[variable];
29239
29268
  return text === void 0 || text.trim() === "" ? void 0 : { text, source: variable };
29240
29269
  }
29270
+ function optionType(spec) {
29271
+ return typeof spec === "string" ? spec : spec.type;
29272
+ }
29273
+ function itemCount(count) {
29274
+ return count === 1 ? "1 item" : `${count} items`;
29275
+ }
29276
+ function numberBoundsError({ integer: integer2, min, max }, source) {
29277
+ const range = min !== void 0 && max !== void 0 ? ` from ${min} to ${max}` : min !== void 0 ? ` of at least ${min}` : max !== void 0 ? ` of at most ${max}` : "";
29278
+ return new ToolError("invalid_argument", `${source} must be ${integer2 ? "a whole number" : "a number"}${range}.`);
29279
+ }
29280
+ function countBoundsError({ min, max }, source) {
29281
+ const range = min !== void 0 && max !== void 0 ? `from ${min} to ${max} items` : min !== void 0 ? `at least ${itemCount(min)}` : `at most ${itemCount(max)}`;
29282
+ return new ToolError("invalid_argument", `${source} must list ${range}.`);
29283
+ }
29284
+ function withinBounds(spec, value, source) {
29285
+ if (value === void 0 || typeof spec === "string") return value;
29286
+ const bounds = spec;
29287
+ const { integer: integer2, min, max } = bounds;
29288
+ if (typeof value === "number") {
29289
+ if (integer2 && !Number.isInteger(value) || min !== void 0 && value < min || max !== void 0 && value > max) {
29290
+ throw numberBoundsError(bounds, source);
29291
+ }
29292
+ } else if (typeof value === "object" && (min !== void 0 && value.length < min || max !== void 0 && value.length > max)) {
29293
+ throw countBoundsError(bounds, source);
29294
+ }
29295
+ return value;
29296
+ }
29297
+ function parseOptionText(spec, given, env) {
29298
+ return withinBounds(spec, parseTypedText(optionType(spec), given, env), given.source);
29299
+ }
29300
+ function parseTypedText(kind, given, env) {
29301
+ switch (kind) {
29302
+ case "text":
29303
+ return nonBlank(given.text);
29304
+ case "boolean":
29305
+ return parseBoolean(given.text, given.source);
29306
+ case "number":
29307
+ return parseNumber(given.text, given.source);
29308
+ case "list": {
29309
+ const items = splitList(given.text);
29310
+ return items.length > 0 ? items : void 0;
29311
+ }
29312
+ case "path": {
29313
+ const text = nonBlank(given.text);
29314
+ return text === void 0 ? void 0 : absolutePath(text, given.source, env);
29315
+ }
29316
+ case "paths":
29317
+ return absolutePaths(splitList(given.text), given.source, env);
29318
+ }
29319
+ }
29241
29320
  function optionsFrom(flags, env, profile) {
29242
29321
  const options = {};
29243
- for (const name of profile.options) {
29244
- const text = nonBlank(flags[name] ?? envText(name, env, profile)?.text);
29245
- if (text !== void 0) options[name] = text;
29322
+ for (const [name, spec] of Object.entries(profileOptions(profile))) {
29323
+ if (flags[name] !== void 0) {
29324
+ const parsed = parseOptionText(spec, { text: flags[name], source: settingOptions(profile)[name].flag }, env);
29325
+ if (parsed !== void 0) options[name] = parsed;
29326
+ continue;
29327
+ }
29328
+ const fromEnv = envText(name, env, profile);
29329
+ if (fromEnv !== void 0) {
29330
+ const parsed = parseOptionText(spec, fromEnv, env);
29331
+ if (parsed !== void 0) options[name] = parsed;
29332
+ }
29246
29333
  }
29247
29334
  return options;
29248
29335
  }
@@ -30375,11 +30462,11 @@ import { stat as stat4 } from "node:fs/promises";
30375
30462
 
30376
30463
  // src/core/commit/lint.ts
30377
30464
  import { readFile, stat as stat3 } from "node:fs/promises";
30378
- import { join as join3 } from "node:path";
30465
+ import { join as join4 } from "node:path";
30379
30466
 
30380
30467
  // src/core/repo/conventions.ts
30381
30468
  import { stat as stat2 } from "node:fs/promises";
30382
- import { join as join2 } from "node:path";
30469
+ import { join as join3 } from "node:path";
30383
30470
  var DEFAULT_SAMPLE = 30;
30384
30471
  var MAX_SAMPLE = 200;
30385
30472
  var SUBJECTS_MAX = 15;
@@ -30421,7 +30508,7 @@ var conventionsSchema = external_exports.object({
30421
30508
  });
30422
30509
  async function isFile(root, path) {
30423
30510
  try {
30424
- return (await stat2(join2(root, path))).isFile();
30511
+ return (await stat2(join3(root, path))).isFile();
30425
30512
  } catch {
30426
30513
  return false;
30427
30514
  }
@@ -30603,7 +30690,7 @@ async function isFile2(path) {
30603
30690
  }
30604
30691
  }
30605
30692
  async function commitlintHook(root, signal) {
30606
- const candidates = [await gitPath(root, "hooks/commit-msg", signal), join3(root, HUSKY_HOOK)];
30693
+ const candidates = [await gitPath(root, "hooks/commit-msg", signal), join4(root, HUSKY_HOOK)];
30607
30694
  for (const path of candidates) {
30608
30695
  let text;
30609
30696
  try {
@@ -30618,10 +30705,10 @@ async function commitlintHook(root, signal) {
30618
30705
  }
30619
30706
  async function commitlintConfigured(root) {
30620
30707
  for (const name of COMMITLINT_CONFIG_FILES) {
30621
- if (await isFile2(join3(root, name))) return true;
30708
+ if (await isFile2(join4(root, name))) return true;
30622
30709
  }
30623
30710
  try {
30624
- const manifest = JSON.parse(await readFile(join3(root, "package.json"), "utf8"));
30711
+ const manifest = JSON.parse(await readFile(join4(root, "package.json"), "utf8"));
30625
30712
  return typeof manifest === "object" && manifest !== null && "commitlint" in manifest;
30626
30713
  } catch {
30627
30714
  return false;
@@ -30629,7 +30716,7 @@ async function commitlintConfigured(root) {
30629
30716
  }
30630
30717
  async function commitlint(root, message, signal) {
30631
30718
  const hook = await commitlintHook(root, signal);
30632
- const binary = join3(root, COMMITLINT_BIN);
30719
+ const binary = join4(root, COMMITLINT_BIN);
30633
30720
  if (await isFile2(binary)) {
30634
30721
  const result = await exec([binary], {
30635
30722
  cwd: root,
@@ -31192,7 +31279,7 @@ async function readAmendCheck(root, sha, signal) {
31192
31279
 
31193
31280
  // src/core/commit/hunks.ts
31194
31281
  import { createHash as createHash2 } from "node:crypto";
31195
- import { isAbsolute as isAbsolute2, relative as relative2, resolve, sep as sep2 } from "node:path";
31282
+ import { isAbsolute as isAbsolute3, relative as relative2, resolve, sep as sep3 } from "node:path";
31196
31283
  var MAX_LISTED_HUNKS = 200;
31197
31284
  var PREVIEW_LINES = 8;
31198
31285
  var DIGEST_LENGTH = 16;
@@ -31324,7 +31411,7 @@ function diffDigest(diff) {
31324
31411
  function checkPaths(root, paths) {
31325
31412
  for (const path of paths) {
31326
31413
  const inside = relative2(root, resolve(root, path));
31327
- if (inside === ".." || inside.startsWith(`..${sep2}`) || isAbsolute2(inside)) {
31414
+ if (inside === ".." || inside.startsWith(`..${sep3}`) || isAbsolute3(inside)) {
31328
31415
  throw new ToolError(
31329
31416
  "invalid_argument",
31330
31417
  `${path} is outside the working tree (${root}); name files or directories inside it.`
@@ -31548,7 +31635,7 @@ var commitStage = defineTool({
31548
31635
  // src/core/commit/verify.ts
31549
31636
  import { mkdtemp, realpath as realpath2, rm, stat as stat5 } from "node:fs/promises";
31550
31637
  import { tmpdir } from "node:os";
31551
- import { isAbsolute as isAbsolute3, join as join4 } from "node:path";
31638
+ import { isAbsolute as isAbsolute4, join as join5 } from "node:path";
31552
31639
  var COMMITS_MAX = 30;
31553
31640
  var OUTPUT_TAIL_LINES = 40;
31554
31641
  var TIMEOUT_DEFAULT_SEC = 600;
@@ -31602,7 +31689,7 @@ ${result.stderr}`.trim().split("\n").slice(-OUTPUT_TAIL_LINES).join("\n");
31602
31689
  }
31603
31690
  function checkDir(dir) {
31604
31691
  const relative3 = dir?.trim() || ".";
31605
- if (isAbsolute3(relative3) || relative3.split(/[\\/]/).includes("..")) {
31692
+ if (isAbsolute4(relative3) || relative3.split(/[\\/]/).includes("..")) {
31606
31693
  throw new ToolError("invalid_argument", "dir must be a relative path inside the repository.");
31607
31694
  }
31608
31695
  return relative3;
@@ -31684,14 +31771,14 @@ var commitVerify = defineTool({
31684
31771
  ctx,
31685
31772
  `Run \`${args.command}\` at each of ${commits.length} commits of ${branch} in a throwaway worktree.`
31686
31773
  );
31687
- const parent = await realpath2(await mkdtemp(join4(tmpdir(), TEMP_DIR_PREFIX)));
31688
- const tree = join4(parent, "tree");
31774
+ const parent = await realpath2(await mkdtemp(join5(tmpdir(), TEMP_DIR_PREFIX)));
31775
+ const tree = join5(parent, "tree");
31689
31776
  const results = [];
31690
31777
  let setup = null;
31691
31778
  try {
31692
31779
  await git(["worktree", "add", "--detach", "--quiet", tree, commits[0]], { cwd: root, signal, hooks: false });
31693
31780
  const run = async (command) => {
31694
- const cwd = join4(tree, dir);
31781
+ const cwd = join5(tree, dir);
31695
31782
  const present = await stat5(cwd).then(
31696
31783
  (stats) => stats.isDirectory(),
31697
31784
  () => false
@@ -31770,7 +31857,7 @@ async function compareBranch(root, remote, selected, expect, signal) {
31770
31857
 
31771
31858
  // src/core/push/published-text.ts
31772
31859
  import { readFile as readFile2, stat as stat6 } from "node:fs/promises";
31773
- import { isAbsolute as isAbsolute4 } from "node:path";
31860
+ import { isAbsolute as isAbsolute5 } from "node:path";
31774
31861
  var PUBLISHED_TEXT_RULES_OPTION = "published_text_rules";
31775
31862
  var MAX_FILE_BYTES = 64 * 1024;
31776
31863
  var MAX_RULES = 100;
@@ -31839,9 +31926,10 @@ function compileRules(path, entries) {
31839
31926
  });
31840
31927
  }
31841
31928
  async function loadPublishedTextRules(options, signal) {
31842
- const path = options?.[PUBLISHED_TEXT_RULES_OPTION]?.trim();
31929
+ const option = options?.[PUBLISHED_TEXT_RULES_OPTION];
31930
+ const path = typeof option === "string" ? option.trim() : void 0;
31843
31931
  if (!path) return [];
31844
- if (!isAbsolute4(path)) throw invalidRules(path, "the value must be an absolute path");
31932
+ if (!isAbsolute5(path)) throw invalidRules(path, "the value must be an absolute path");
31845
31933
  return compileRules(path, parseRulesJson(path, await readRulesFile(path, signal)));
31846
31934
  }
31847
31935
  var publishedTextFindingSchema = external_exports.object({
@@ -32796,11 +32884,11 @@ var rebaseRead = defineTool({
32796
32884
  // src/core/rebase/resume.ts
32797
32885
  import { createHash as createHash3 } from "node:crypto";
32798
32886
  import { readFile as readFile5 } from "node:fs/promises";
32799
- import { join as join7 } from "node:path";
32887
+ import { join as join8 } from "node:path";
32800
32888
 
32801
32889
  // src/core/conflicts.ts
32802
32890
  import { readFile as readFile3 } from "node:fs/promises";
32803
- import { basename as basename2, join as join5 } from "node:path";
32891
+ import { basename as basename2, join as join6 } from "node:path";
32804
32892
  var REGION_MAX = 20;
32805
32893
  var SIDE_LINES_MAX = 40;
32806
32894
  var LOCKFILES = {
@@ -32912,7 +33000,7 @@ async function readConflictFile(root, path, xy, operation) {
32912
33000
  }
32913
33001
  let text;
32914
33002
  try {
32915
- text = await readFile3(join5(root, path), "utf8");
33003
+ text = await readFile3(join6(root, path), "utf8");
32916
33004
  } catch {
32917
33005
  return { ...file2, region_count: 0, regions: [] };
32918
33006
  }
@@ -32995,13 +33083,13 @@ var rebaseOutcomeSchema = external_exports.object({
32995
33083
 
32996
33084
  // src/core/rebase/rebase-state.ts
32997
33085
  import { readFile as readFile4 } from "node:fs/promises";
32998
- import { join as join6 } from "node:path";
33086
+ import { join as join7 } from "node:path";
32999
33087
  var HEADS_PREFIX = "refs/heads/";
33000
33088
  var STATE_DIRS = ["rebase-merge", "rebase-apply"];
33001
33089
  var UPDATE_REFS_LINES = 3;
33002
33090
  async function readStateFile(dir, name) {
33003
33091
  try {
33004
- return (await readFile4(join6(dir, name), "utf8")).trim();
33092
+ return (await readFile4(join7(dir, name), "utf8")).trim();
33005
33093
  } catch {
33006
33094
  return void 0;
33007
33095
  }
@@ -33176,7 +33264,7 @@ async function conflictedFiles(root, signal) {
33176
33264
  for (const path of await unmergedPaths(root, signal)) {
33177
33265
  let content;
33178
33266
  try {
33179
- content = await readFile5(join7(root, path));
33267
+ content = await readFile5(join8(root, path));
33180
33268
  } catch {
33181
33269
  content = null;
33182
33270
  }
@@ -34175,8 +34263,8 @@ var repoRead = defineTool({
34175
34263
  import { lstat } from "node:fs/promises";
34176
34264
 
34177
34265
  // src/core/worktree/layout.ts
34178
- import { homedir } from "node:os";
34179
- import { basename as basename3, dirname as dirname3, isAbsolute as isAbsolute5, join as join8, resolve as resolve2 } from "node:path";
34266
+ import { homedir as homedir2 } from "node:os";
34267
+ import { basename as basename3, dirname as dirname3, isAbsolute as isAbsolute6, join as join9, resolve as resolve2 } from "node:path";
34180
34268
  var WORKTREE_ROOT_OPTION = "worktree_root";
34181
34269
  var DEFAULT_ROOT_FOLDER = "worktrees";
34182
34270
  var HOME_MARK = "~";
@@ -34187,17 +34275,18 @@ var URL_SEPARATOR = /[/:]/;
34187
34275
  var TRAILING_SLASHES = /\/+$/;
34188
34276
  var NOT_A_NAME = [".", ".."];
34189
34277
  function homeDirectory() {
34190
- return process.env.HOME?.trim() || homedir();
34278
+ return process.env.HOME?.trim() || homedir2();
34191
34279
  }
34192
34280
  function expandHome(value) {
34193
34281
  if (value === HOME_MARK) return homeDirectory();
34194
- return value.startsWith(HOME_PREFIX) ? join8(homeDirectory(), value.slice(HOME_PREFIX.length)) : value;
34282
+ return value.startsWith(HOME_PREFIX) ? join9(homeDirectory(), value.slice(HOME_PREFIX.length)) : value;
34195
34283
  }
34196
34284
  function worktreeRoot(ctx) {
34197
- const value = ctx.options?.[WORKTREE_ROOT_OPTION]?.trim();
34198
- if (!value) return join8(homeDirectory(), DEFAULT_ROOT_FOLDER);
34285
+ const option = ctx.options?.[WORKTREE_ROOT_OPTION];
34286
+ const value = typeof option === "string" ? option.trim() : void 0;
34287
+ if (!value) return join9(homeDirectory(), DEFAULT_ROOT_FOLDER);
34199
34288
  const expanded = expandHome(value);
34200
- if (!isAbsolute5(expanded)) {
34289
+ if (!isAbsolute6(expanded)) {
34201
34290
  throw new ToolError(
34202
34291
  "invalid_argument",
34203
34292
  `The ${WORKTREE_ROOT_OPTION} setting (${value}): the value must be an absolute path, or start with ${HOME_PREFIX}.`
@@ -34219,7 +34308,7 @@ async function repositoryName(root, signal) {
34219
34308
  return fromUrl || basename3(await mainCheckout(root, signal)).replace(GIT_SUFFIX, "");
34220
34309
  }
34221
34310
  function worktreePath(root, repository, branch) {
34222
- return join8(root, repository, branch.replaceAll("/", "-"));
34311
+ return join9(root, repository, branch.replaceAll("/", "-"));
34223
34312
  }
34224
34313
 
34225
34314
  // src/core/worktree/add-plan.ts
@@ -34482,7 +34571,7 @@ var worktreeAdd = defineTool({
34482
34571
  });
34483
34572
 
34484
34573
  // src/core/worktree/list.ts
34485
- import { dirname as dirname5, join as join9 } from "node:path";
34574
+ import { dirname as dirname5, join as join10 } from "node:path";
34486
34575
  var worktreeSchema = external_exports.object({
34487
34576
  directory: external_exports.string().describe("The worktree's directory: its real path when it exists, else as git prints it."),
34488
34577
  branch: external_exports.string().nullable().describe("The checked-out branch's name; null when HEAD is detached or bare."),
@@ -34517,7 +34606,7 @@ async function readWorktreeList(callerRoot, root, signal) {
34517
34606
  const repository = await repositoryName(callerRoot, signal);
34518
34607
  const [caller, layoutFolder, entries] = await Promise.all([
34519
34608
  realpathOrSelf(callerRoot),
34520
- realpathOfExisting(join9(root, repository)),
34609
+ realpathOfExisting(join10(root, repository)),
34521
34610
  listWorktrees(callerRoot, signal)
34522
34611
  ]);
34523
34612
  return {
@@ -34696,7 +34785,7 @@ async function planRemove(request) {
34696
34785
 
34697
34786
  // src/core/worktree/remove-run.ts
34698
34787
  import { rmdir } from "node:fs/promises";
34699
- import { dirname as dirname6, join as join10 } from "node:path";
34788
+ import { dirname as dirname6, join as join11 } from "node:path";
34700
34789
 
34701
34790
  // src/core/worktree/landed.ts
34702
34791
  var LANDED_READ_TIMEOUT_MS = PROTECTION_READ_TIMEOUT_MS;
@@ -34778,7 +34867,7 @@ async function removeWorktreeRecord(request, plan) {
34778
34867
  }
34779
34868
  async function removeEmptyLayoutFolder(request, plan) {
34780
34869
  const { callerRoot, worktreeRoot: worktreeRoot2, signal } = request;
34781
- const folder = await realpathOfExisting(join10(worktreeRoot2, await repositoryName(callerRoot, signal)));
34870
+ const folder = await realpathOfExisting(join11(worktreeRoot2, await repositoryName(callerRoot, signal)));
34782
34871
  const parent = dirname6(plan.directory);
34783
34872
  if (await realpathOfExisting(parent) !== folder) return;
34784
34873
  try {
@@ -36699,23 +36788,52 @@ function elicitationConfirm(server, signal) {
36699
36788
 
36700
36789
  // src/mcp/roots.ts
36701
36790
  import { statSync } from "node:fs";
36791
+ import { isAbsolute as isAbsolute7 } from "node:path";
36702
36792
  import { fileURLToPath as fileURLToPath2 } from "node:url";
36703
36793
  var FILE_URI_PROTOCOL = "file:";
36794
+ var SANDBOX_STATE_META = "codex/sandbox-state-meta";
36795
+ var TURN_METADATA = "x-codex-turn-metadata";
36796
+ function existingDirectory(dir) {
36797
+ try {
36798
+ return statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? dir : void 0;
36799
+ } catch {
36800
+ return void 0;
36801
+ }
36802
+ }
36704
36803
  function localDirectory(uri) {
36705
36804
  try {
36706
36805
  const url2 = new URL(uri);
36707
- if (url2.protocol !== FILE_URI_PROTOCOL) return void 0;
36708
- const dir = fileURLToPath2(url2);
36709
- return statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? dir : void 0;
36806
+ return url2.protocol === FILE_URI_PROTOCOL ? existingDirectory(fileURLToPath2(url2)) : void 0;
36710
36807
  } catch {
36711
36808
  return void 0;
36712
36809
  }
36713
36810
  }
36811
+ function isRecord(value) {
36812
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36813
+ }
36814
+ async function usableDirectory(candidate, ownDir, allowPath = false) {
36815
+ if (typeof candidate !== "string") return void 0;
36816
+ const dir = allowPath && isAbsolute7(candidate) ? existingDirectory(candidate) : localDirectory(candidate);
36817
+ if (dir === void 0 || ownDir !== void 0 && await isWithin(dir, ownDir)) return void 0;
36818
+ return dir;
36819
+ }
36820
+ async function callDirectory(meta3, ownDir) {
36821
+ if (!isRecord(meta3)) return void 0;
36822
+ const sandbox = meta3[SANDBOX_STATE_META];
36823
+ if (isRecord(sandbox)) {
36824
+ const dir = await usableDirectory(sandbox.sandboxCwd, ownDir, true);
36825
+ if (dir !== void 0) return dir;
36826
+ }
36827
+ const turn = meta3[TURN_METADATA];
36828
+ if (!isRecord(turn) || !isRecord(turn.workspaces)) return void 0;
36829
+ const workspaces = Object.keys(turn.workspaces);
36830
+ return workspaces.length === 1 ? usableDirectory(workspaces[0], ownDir, true) : void 0;
36831
+ }
36714
36832
  async function firstUsableRoot(server, ownDir, signal) {
36715
36833
  const { roots } = await server.listRoots(void 0, { signal });
36716
36834
  for (const root of roots) {
36717
- const dir = localDirectory(root.uri);
36718
- if (dir !== void 0 && !(ownDir !== void 0 && await isWithin(dir, ownDir))) return dir;
36835
+ const dir = await usableDirectory(root.uri, ownDir);
36836
+ if (dir !== void 0) return dir;
36719
36837
  }
36720
36838
  return void 0;
36721
36839
  }
@@ -36726,7 +36844,9 @@ function createWorkingDirResolver(server, fallback, ownDir) {
36726
36844
  cached2 = void 0;
36727
36845
  generation++;
36728
36846
  });
36729
- return async (signal) => {
36847
+ return async (signal, meta3) => {
36848
+ const callDir = await callDirectory(meta3, ownDir);
36849
+ if (callDir !== void 0) return callDir;
36730
36850
  if (!server.getClientCapabilities()?.roots) return fallback();
36731
36851
  if (cached2 !== void 0) return cached2;
36732
36852
  const started = generation;
@@ -36767,7 +36887,7 @@ function createServer(specs, settings, options = {}) {
36767
36887
  `));
36768
36888
  const server = new Server(
36769
36889
  { name: PROJECT.name, version: options.version ?? DEFAULT_VERSION },
36770
- { capabilities: { tools: {} } }
36890
+ { capabilities: { tools: {}, experimental: { [SANDBOX_STATE_META]: {} } } }
36771
36891
  );
36772
36892
  const workingDir = createWorkingDirResolver(server, options.cwd ?? (() => process.cwd()), options.ownDir);
36773
36893
  const toolsets = options.toolsets ?? PROJECT_PROFILE.toolsets;
@@ -36780,7 +36900,7 @@ function createServer(specs, settings, options = {}) {
36780
36900
  try {
36781
36901
  const confirm = settings.confirm === "elicitation" ? elicitationConfirm(server, extra.signal) : void 0;
36782
36902
  const ctx = {
36783
- cwd: await workingDir(extra.signal),
36903
+ cwd: await workingDir(extra.signal, request.params._meta),
36784
36904
  ownDir: options.ownDir,
36785
36905
  options: settings.options,
36786
36906
  signal: extra.signal,
@@ -36808,7 +36928,7 @@ async function main() {
36808
36928
  const settings = parseSettings(process.argv.slice(2), process.env);
36809
36929
  server = createServer(ALL_TOOLS, settings, {
36810
36930
  ownDir: packageRoot(import.meta.url),
36811
- version: true ? "0.1.0" : void 0
36931
+ version: true ? "0.2.0" : void 0
36812
36932
  });
36813
36933
  } catch (error62) {
36814
36934
  if (!isToolError(error62)) throw error62;
package/dist/opencode.js CHANGED
@@ -25,8 +25,8 @@ var PROJECT = {
25
25
  toolsets: ["repo", "commit", "rebase", "worktree", "push"],
26
26
  /** The toolsets on when a setting names none. */
27
27
  defaultToolsets: ["repo", "commit", "rebase"],
28
- /** The settings the tools themselves read, beside the ones every entry point takes; each holds text and is named in snake_case. */
29
- options: ["published_text_rules", "worktree_root"],
28
+ /** The package's own settings, which the tools themselves read: each snake_case name and its type, with bounds when it needs them. */
29
+ options: { published_text_rules: "text", worktree_root: "text" },
30
30
  /** The environment variables the tools read besides the settings, which Codex forwards to the server after them. */
31
31
  toolVariables: [
32
32
  "LANGUAGE",
@@ -22980,7 +22980,8 @@ function compileRules(path, entries) {
22980
22980
  });
22981
22981
  }
22982
22982
  async function loadPublishedTextRules(options, signal) {
22983
- const path = options?.[PUBLISHED_TEXT_RULES_OPTION]?.trim();
22983
+ const option = options?.[PUBLISHED_TEXT_RULES_OPTION];
22984
+ const path = typeof option === "string" ? option.trim() : void 0;
22984
22985
  if (!path) return [];
22985
22986
  if (!isAbsolute4(path)) throw invalidRules(path, "the value must be an absolute path");
22986
22987
  return compileRules(path, parseRulesJson(path, await readRulesFile(path, signal)));
@@ -25335,7 +25336,8 @@ function expandHome(value) {
25335
25336
  return value.startsWith(HOME_PREFIX) ? join8(homeDirectory(), value.slice(HOME_PREFIX.length)) : value;
25336
25337
  }
25337
25338
  function worktreeRoot(ctx) {
25338
- const value = ctx.options?.[WORKTREE_ROOT_OPTION]?.trim();
25339
+ const option = ctx.options?.[WORKTREE_ROOT_OPTION];
25340
+ const value = typeof option === "string" ? option.trim() : void 0;
25339
25341
  if (!value) return join8(homeDirectory(), DEFAULT_ROOT_FOLDER);
25340
25342
  const expanded = expandHome(value);
25341
25343
  if (!isAbsolute5(expanded)) {
@@ -26454,6 +26456,8 @@ var PROJECT_PROFILE = {
26454
26456
  };
26455
26457
 
26456
26458
  // src/entry/settings.ts
26459
+ import { homedir as homedir2 } from "node:os";
26460
+ import { isAbsolute as isAbsolute6, join as join11, resolve as resolvePath, sep as sep3 } from "node:path";
26457
26461
  var ALL_TOOLSETS = "all";
26458
26462
  var TRUE_VALUES = ["1", "true"];
26459
26463
  var FALSE_VALUES = ["0", "false"];
@@ -26464,10 +26468,17 @@ var OPTIONS = {
26464
26468
  exclude_tools: { flag: "--exclude-tools", envSuffix: "EXCLUDE_TOOLS" },
26465
26469
  confirm: { flag: "--confirm", envSuffix: "CONFIRM" }
26466
26470
  };
26471
+ function profileOptions(profile) {
26472
+ return profile.options ?? {};
26473
+ }
26467
26474
  function settingOptions(profile) {
26468
- const toolOptions = profile.options.map((name) => [
26475
+ const toolOptions = Object.entries(profileOptions(profile)).map(([name, spec]) => [
26469
26476
  name,
26470
- { flag: `--${name.replace(/_/g, "-")}`, envSuffix: name.toUpperCase() }
26477
+ {
26478
+ flag: `--${name.replace(/_/g, "-")}`,
26479
+ envSuffix: name.toUpperCase(),
26480
+ ...optionType(spec) === "boolean" && { boolean: true }
26481
+ }
26471
26482
  ]);
26472
26483
  return { ...OPTIONS, ...Object.fromEntries(toolOptions) };
26473
26484
  }
@@ -26495,11 +26506,78 @@ function parseBoolean(value, source) {
26495
26506
  `Unknown value "${value}" in ${source}; use one of ${[...TRUE_VALUES, ...FALSE_VALUES].join(", ")}.`
26496
26507
  );
26497
26508
  }
26509
+ function parseNumber(value, source) {
26510
+ const trimmed2 = value.trim();
26511
+ const parsed = trimmed2 === "" ? Number.NaN : Number(trimmed2);
26512
+ if (!Number.isFinite(parsed)) {
26513
+ throw new ToolError("invalid_argument", `Unknown number "${value}" in ${source}; use a finite number.`);
26514
+ }
26515
+ return parsed;
26516
+ }
26517
+ function absolutePath(text, source, env) {
26518
+ const home = env.HOME !== void 0 && isAbsolute6(env.HOME) ? env.HOME : homedir2();
26519
+ const expanded = text === "~" || text.startsWith("~/") || text.startsWith(`~${sep3}`) ? join11(home, text.slice(1)) : text;
26520
+ if (!isAbsolute6(expanded)) throw new ToolError("invalid_argument", `${source} must be an absolute path.`);
26521
+ return resolvePath(expanded);
26522
+ }
26523
+ function absolutePaths(items, source, env) {
26524
+ return items.length > 0 ? items.map((item) => absolutePath(item, source, env)) : void 0;
26525
+ }
26498
26526
  function envText(name, env, profile) {
26499
26527
  const variable = envName(name, profile);
26500
26528
  const text = env[variable];
26501
26529
  return text === void 0 || text.trim() === "" ? void 0 : { text, source: variable };
26502
26530
  }
26531
+ function optionType(spec) {
26532
+ return typeof spec === "string" ? spec : spec.type;
26533
+ }
26534
+ function itemCount(count) {
26535
+ return count === 1 ? "1 item" : `${count} items`;
26536
+ }
26537
+ function numberBoundsError({ integer: integer2, min, max }, source) {
26538
+ const range = min !== void 0 && max !== void 0 ? ` from ${min} to ${max}` : min !== void 0 ? ` of at least ${min}` : max !== void 0 ? ` of at most ${max}` : "";
26539
+ return new ToolError("invalid_argument", `${source} must be ${integer2 ? "a whole number" : "a number"}${range}.`);
26540
+ }
26541
+ function countBoundsError({ min, max }, source) {
26542
+ const range = min !== void 0 && max !== void 0 ? `from ${min} to ${max} items` : min !== void 0 ? `at least ${itemCount(min)}` : `at most ${itemCount(max)}`;
26543
+ return new ToolError("invalid_argument", `${source} must list ${range}.`);
26544
+ }
26545
+ function withinBounds(spec, value, source) {
26546
+ if (value === void 0 || typeof spec === "string") return value;
26547
+ const bounds = spec;
26548
+ const { integer: integer2, min, max } = bounds;
26549
+ if (typeof value === "number") {
26550
+ if (integer2 && !Number.isInteger(value) || min !== void 0 && value < min || max !== void 0 && value > max) {
26551
+ throw numberBoundsError(bounds, source);
26552
+ }
26553
+ } else if (typeof value === "object" && (min !== void 0 && value.length < min || max !== void 0 && value.length > max)) {
26554
+ throw countBoundsError(bounds, source);
26555
+ }
26556
+ return value;
26557
+ }
26558
+ function parseOptionText(spec, given, env) {
26559
+ return withinBounds(spec, parseTypedText(optionType(spec), given, env), given.source);
26560
+ }
26561
+ function parseTypedText(kind, given, env) {
26562
+ switch (kind) {
26563
+ case "text":
26564
+ return nonBlank(given.text);
26565
+ case "boolean":
26566
+ return parseBoolean(given.text, given.source);
26567
+ case "number":
26568
+ return parseNumber(given.text, given.source);
26569
+ case "list": {
26570
+ const items = splitList(given.text);
26571
+ return items.length > 0 ? items : void 0;
26572
+ }
26573
+ case "path": {
26574
+ const text = nonBlank(given.text);
26575
+ return text === void 0 ? void 0 : absolutePath(text, given.source, env);
26576
+ }
26577
+ case "paths":
26578
+ return absolutePaths(splitList(given.text), given.source, env);
26579
+ }
26580
+ }
26503
26581
  var TOOL_OPTION_NAMES = ["toolsets", "read_only", "tools", "exclude_tools"];
26504
26582
  function stringList(value) {
26505
26583
  return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
@@ -26526,7 +26604,7 @@ function listOption(name) {
26526
26604
  }
26527
26605
  var LANGUAGE_OPTION = "language";
26528
26606
  function pluginOptionNames(profile) {
26529
- return [...TOOL_OPTION_NAMES, LANGUAGE_OPTION, ...profile.options];
26607
+ return [...TOOL_OPTION_NAMES, LANGUAGE_OPTION, ...Object.keys(profileOptions(profile))];
26530
26608
  }
26531
26609
  function languageVariable(profile = PROJECT_PROFILE) {
26532
26610
  return `${profile.envPrefix}_LANGUAGE`;
@@ -26542,9 +26620,41 @@ function languageOption(value) {
26542
26620
  if (typeof value !== "string") throw optionError(LANGUAGE_OPTION, 'a language tag such as "ja"');
26543
26621
  return nonBlank(value);
26544
26622
  }
26545
- function textOption(name, value) {
26546
- if (typeof value !== "string") throw optionError(name, "text");
26547
- return nonBlank(value);
26623
+ function toolOption(name, spec, value, env) {
26624
+ return withinBounds(spec, toolOptionOfType(name, optionType(spec), value, env), `Option "${name}"`);
26625
+ }
26626
+ function toolOptionOfType(name, kind, value, env) {
26627
+ switch (kind) {
26628
+ case "text":
26629
+ if (typeof value !== "string") throw optionError(name, "text");
26630
+ return nonBlank(value);
26631
+ case "boolean":
26632
+ if (typeof value !== "boolean") throw optionError(name, "true or false");
26633
+ return value;
26634
+ case "number":
26635
+ if (typeof value !== "number" || !Number.isFinite(value)) throw optionError(name, "a finite number");
26636
+ return value;
26637
+ case "list": {
26638
+ const list = stringList(value);
26639
+ if (!list) throw optionError(name, "a list of strings");
26640
+ const items = list.map((item) => item.trim()).filter((item) => item !== "");
26641
+ return items.length > 0 ? items : void 0;
26642
+ }
26643
+ case "path": {
26644
+ if (typeof value !== "string") throw optionError(name, "an absolute path");
26645
+ const text = nonBlank(value);
26646
+ return text === void 0 ? void 0 : absolutePath(text, `Option "${name}"`, env);
26647
+ }
26648
+ case "paths": {
26649
+ const list = stringList(value);
26650
+ if (!list) throw optionError(name, "a list of absolute paths");
26651
+ return absolutePaths(
26652
+ list.flatMap((item) => nonBlank(item) ?? []),
26653
+ `Option "${name}"`,
26654
+ env
26655
+ );
26656
+ }
26657
+ }
26548
26658
  }
26549
26659
  function settingsFromOptions(options, env, profile = PROJECT_PROFILE) {
26550
26660
  const given = options ?? {};
@@ -26558,12 +26668,18 @@ function settingsFromOptions(options, env, profile = PROJECT_PROFILE) {
26558
26668
  const text = envText(name, env, profile);
26559
26669
  return text !== void 0 ? fromText(text) : void 0;
26560
26670
  };
26561
- const toolOptions = {};
26562
- for (const name of profile.options) {
26563
- const text = given[name] !== void 0 ? textOption(name, given[name]) : nonBlank(envText(name, env, profile)?.text);
26564
- if (text !== void 0) toolOptions[name] = text;
26671
+ const parsedOptions = {};
26672
+ for (const [name, spec] of Object.entries(profileOptions(profile))) {
26673
+ let parsed;
26674
+ if (given[name] !== void 0) {
26675
+ parsed = toolOption(name, spec, given[name], env);
26676
+ } else {
26677
+ const fromEnv = envText(name, env, profile);
26678
+ if (fromEnv !== void 0) parsed = parseOptionText(spec, fromEnv, env);
26679
+ }
26680
+ if (parsed !== void 0) parsedOptions[name] = parsed;
26565
26681
  }
26566
- const settings = { options: toolOptions };
26682
+ const settings = { options: parsedOptions };
26567
26683
  const toolsets = resolve5(
26568
26684
  "toolsets",
26569
26685
  (value) => toolsetsOption(value, profile.toolsets),
@@ -26669,7 +26785,7 @@ function createV1Server(specs, deps = {}) {
26669
26785
 
26670
26786
  // src/opencode/skills.ts
26671
26787
  import { readFile as readFile6, readdir } from "node:fs/promises";
26672
- import { join as join11, resolve as resolve4 } from "node:path";
26788
+ import { join as join12, resolve as resolve4 } from "node:path";
26673
26789
  import { fileURLToPath as fileURLToPath2 } from "node:url";
26674
26790
  var SKILL_FILE = "SKILL.md";
26675
26791
  var FRONTMATTER = /^---[ \t]*\r?\n([\s\S]*?\r?\n)?---[ \t]*(?:\r?\n|$)/;
@@ -26770,7 +26886,7 @@ async function readSkills(dir) {
26770
26886
  }
26771
26887
  const skills = /* @__PURE__ */ new Map();
26772
26888
  for (const directory of entries.sort()) {
26773
- const path = join11(root, directory, SKILL_FILE);
26889
+ const path = join12(root, directory, SKILL_FILE);
26774
26890
  const text = await readSkillFile(path);
26775
26891
  if (text === void 0) continue;
26776
26892
  const found = parseSkill(directory, text, path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craftsamo/git-tools",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Git tools for coding agents: state, commits, rewrites and stacked branches, worktrees and pushing.",
5
5
  "author": "craftsamo",
6
6
  "license": "LicenseRef-AGPL-3.0-Commons-Clause",
package/plugin.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  name: git-tools
2
- version: 0.1.0
2
+ version: 0.2.0
3
3
  description: "Git tools for coding agents: state, commits, rewrites and stacked branches, worktrees and pushing."
4
4
  manifest_version: 1
5
5
  requires_hermes: ">=0.22"
@@ -35,6 +35,9 @@ config_schema:
35
35
  description: Single tools to remove by id. Wins over tools and toolsets.
36
36
  language:
37
37
  description: The language of the approval card, such as ja. Defaults to Hermes's display language, else English.
38
+ emoji:
39
+ type: dict
40
+ description: 'The emoji shown beside each toolset, by toolset name; "" shows none. Merged over the package''s emoji.'
38
41
  published_text_rules:
39
42
  description: The absolute path of a JSON file of published-text rules for the push tools. Unset, nothing is checked.
40
43
  worktree_root:
@@ -55,8 +55,8 @@ below, and returns to the branch it started on.
55
55
  replays; a commit whose change the base already has is dropped. `onto`
56
56
  names another commit or ref.
57
57
  - **Folding fixups in place** (`keep_base`): replays on the current base with
58
- no fetch, folding `fixup!`, `squash!` and `amend!` commits into their
59
- targets.
58
+ no fetch, folding each commit marked `fixup!` or `squash!` or `amend!`
59
+ into its target.
60
60
  - **After a lower layer landed** (`after`): when a layer was merged by a
61
61
  squash, its commits are not on the default branch as they are. `after`
62
62
  names that layer: only the layers above it are replayed onto the new base,
@@ -162,18 +162,13 @@ def resolve_target(
162
162
  """What ``target`` prints: ``["*"]`` for resources that cannot be told, no description for one absent or bad.
163
163
 
164
164
  ``language`` is the plugin's ``language`` setting and ``harness_language`` reads Hermes' display language
165
- per call; neither may fail the run, and ``target`` picks among them. ``option_flags`` are the flags of the
166
- plugin's tool options, which the call's description may read as ``run`` does.
165
+ per call; neither may fail the run, and ``target`` picks among them.
167
166
  """
168
167
  try:
169
168
  result = runtime.run(
170
169
  [
171
- "target",
172
- tool_id,
173
- "--cwd",
174
- agent_cwd(),
175
- *language_flags(language, _known(harness_language)),
176
- *(option_flags or []),
170
+ "target", tool_id, "--cwd", agent_cwd(),
171
+ *language_flags(language, _known(harness_language)), *(option_flags or []),
177
172
  ],
178
173
  stdin=json.dumps(run_arguments(args)),
179
174
  timeout=TARGET_TIMEOUT_SECONDS,
@@ -232,8 +227,7 @@ def make_hook(
232
227
 
233
228
  Whether a person is there to ask, and what happens when none is, is
234
229
  Hermes' own gate to decide. ``language`` is the plugin's ``language``
235
- setting; ``harness_language`` is read on every call; ``option_flags``
236
- are the flags of the plugin's tool options, passed to ``target``. When ``pins`` is
230
+ setting; ``harness_language`` is read on every call. When ``pins`` is
237
231
  given, the pin ``target`` answered is kept in it under the call's
238
232
  ``pin_key`` for the pin hook; the decision is the same either way.
239
233
  """
@@ -309,7 +303,6 @@ def register_approval(
309
303
  ) -> None:
310
304
  """Hold every non-read call of ``tools`` for approval, with the card in ``language`` (the plugin's setting).
311
305
 
312
- ``option_flags`` are the flags of the plugin's tool options, which ``target`` reads as ``run`` does.
313
306
  Two hooks, in this order, since Hermes runs them in registration order: the approval hook
314
307
  stashes the pin of what the card shows, then the pin hook gives it to the call.
315
308
  """
@@ -317,12 +310,7 @@ def register_approval(
317
310
  ctx.register_hook(
318
311
  HOOK_NAME,
319
312
  make_hook(
320
- runtime,
321
- tools,
322
- surface=lambda: session_surface(ctx),
323
- language=language,
324
- pins=pins,
325
- option_flags=option_flags,
313
+ runtime, tools, surface=lambda: session_surface(ctx), language=language, pins=pins, option_flags=option_flags
326
314
  ),
327
315
  )
328
316
  ctx.register_hook(HOOK_NAME, make_pin_hook(tools, pins))
@@ -9,7 +9,7 @@ from .context import PluginContext
9
9
  from .listing import list_tools
10
10
  from . import project
11
11
  from .runtime import Unavailable, locate_runtime
12
- from .settings import SettingsError, language_setting, option_flags, settings_flags
12
+ from .settings import SettingsError, emoji_setting, language_setting, option_flags, settings_flags
13
13
  from .skills import register_skills
14
14
  from .tools import register_tools
15
15
 
@@ -33,6 +33,6 @@ def register(ctx: PluginContext) -> None:
33
33
  logger.warning("%s is not available and registers nothing: %s", project.PLUGIN_NAME, error)
34
34
  return
35
35
 
36
- register_tools(ctx, runtime, tools, flags)
36
+ register_tools(ctx, runtime, tools, flags, emoji_setting(ctx))
37
37
  register_approval(ctx, runtime, tools, language_setting(ctx), option_flags(ctx))
38
38
  register_skills(ctx, runtime.skills_dir, {tool.toolset for tool in tools})
@@ -14,9 +14,10 @@ SKILL_PREFIX = "git-"
14
14
  #: The emoji each toolset is shown with; empty by default until a project adds its own.
15
15
  TOOLSET_EMOJI: dict[str, str] = {}
16
16
 
17
- #: The settings the tools themselves read, beside the ones every plugin takes: each holds text and is named in
18
- #: snake_case, as in ``src/project.ts``. A setting ``a_b`` becomes the flag ``--a-b`` of the command line.
19
- OPTIONS: tuple[str, ...] = ("published_text_rules", "worktree_root")
17
+ #: The settings the tools themselves read: each snake_case name and a type (``text``, ``boolean``, ``number``,
18
+ #: ``list``, ``path`` or ``paths``) or a dict with ``type`` and optional bounds (``integer``, ``min``, ``max``),
19
+ #: as in ``src/project.ts``. Write OPTIONS on one line with double quotes. A setting ``a_b`` becomes ``--a-b``.
20
+ OPTIONS: dict[str, str | dict[str, object]] = {"published_text_rules": "text", "worktree_root": "text"}
20
21
 
21
22
  __all__ = [
22
23
  "CLI_NAME",
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import math
6
+ from collections.abc import Mapping
5
7
  from typing import Any
6
8
 
7
9
  from . import project
@@ -31,30 +33,60 @@ def language_setting(ctx: PluginContext) -> str | None:
31
33
  return (value.strip() or None) if isinstance(value, str) else None
32
34
 
33
35
 
34
- def option_flags(ctx: PluginContext) -> list[str]:
35
- """Read the tool options (``project.OPTIONS``) and return the flags that say the same, each ``--a-b <text>``.
36
+ def emoji_setting(ctx: PluginContext, defaults: Mapping[str, str] | None = None) -> dict[str, str]:
37
+ """The emoji per toolset: ``defaults`` (``project.TOOLSET_EMOJI``) with string entries of ``emoji`` merged over it.
36
38
 
37
- An option that is absent or blank gives no flag, so the command line's own
38
- variable (``GIT_TOOLS_<OPTION>``) applies. These are the only flags
39
- ``target`` takes beside its language ones.
39
+ A setting that is not a ``dict``, and an entry whose key or value is not a
40
+ ``str``, are ignored so a typo never takes every tool away. It is no flag
41
+ of ``settings_flags``.
42
+ """
43
+ merged = dict(project.TOOLSET_EMOJI if defaults is None else defaults)
44
+ value = ctx.get_config("emoji")
45
+ if isinstance(value, dict):
46
+ merged.update({key: item for key, item in value.items() if isinstance(key, str) and isinstance(item, str)})
47
+ return merged
40
48
 
41
- Raises:
42
- SettingsError: an option is not text.
49
+
50
+ def _option_flag(name: str, declaration: str | dict[str, object], value: Any) -> list[str]:
51
+ kind = declaration["type"] if isinstance(declaration, dict) else declaration
52
+ flag = f"--{name.replace('_', '-')}"
53
+ if kind in ("text", "path"):
54
+ if not isinstance(value, str):
55
+ expected = "a path" if kind == "path" else "text"
56
+ raise SettingsError(f'setting "{name}" must be {expected}')
57
+ return [flag, text] if (text := value.strip()) else []
58
+ if kind == "boolean":
59
+ if not isinstance(value, bool):
60
+ raise SettingsError(f'setting "{name}" must be true or false')
61
+ return [flag] if value else []
62
+ if kind == "number":
63
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
64
+ raise SettingsError(f'setting "{name}" must be a finite number')
65
+ return [flag, str(value)]
66
+ if kind in ("list", "paths"):
67
+ if not isinstance(value, list) or not all(isinstance(item, str) and "," not in item for item in value):
68
+ expected = "paths" if kind == "paths" else "strings"
69
+ raise SettingsError(f'setting "{name}" must be a list of {expected} without commas')
70
+ items = [item.strip() for item in value if item.strip()]
71
+ return [flag, ",".join(items)] if items else []
72
+ raise SettingsError(f'unknown option type "{kind}" for "{name}"')
73
+
74
+
75
+ def option_flags(ctx: PluginContext, options: Mapping[str, str | dict[str, object]] | None = None) -> list[str]:
76
+ """The package's tool options as flags, with absent settings left to the command line's environment.
77
+
78
+ Bounds and path absoluteness are checked by the command line, not here.
43
79
  """
44
80
  flags: list[str] = []
45
- for name in project.OPTIONS:
81
+ for name, declaration in (project.OPTIONS if options is None else options).items():
46
82
  value = ctx.get_config(name)
47
- if value is None:
48
- continue
49
- if not isinstance(value, str):
50
- raise SettingsError(f'setting "{name}" must be text')
51
- if text := value.strip():
52
- flags += [f"--{name.replace('_', '-')}", text]
83
+ if value is not None:
84
+ flags += _option_flag(name, declaration, value)
53
85
  return flags
54
86
 
55
87
 
56
88
  def settings_flags(ctx: PluginContext) -> list[str]:
57
- """Read ``toolsets``, ``read_only``, ``tools``, ``exclude_tools`` and the tool options and return the flags that say the same.
89
+ """Read ``toolsets``, ``read_only``, ``tools`` and ``exclude_tools`` and return the flags that say the same.
58
90
 
59
91
  A setting that is absent gives no flag, so the command line's own defaults
60
92
  (and ``GIT_TOOLS_*`` variables) apply. A wrong type is refused rather
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from collections.abc import Callable
6
+ from collections.abc import Callable, Mapping
7
7
  from typing import Any
8
8
 
9
9
  from .approval import PIN_ARGUMENT, parse_pin, run_arguments
@@ -76,14 +76,21 @@ def make_handler(runtime: Runtime, tool_id: str, flags: list[str]) -> Callable[.
76
76
  return handler
77
77
 
78
78
 
79
- def register_tools(ctx: PluginContext, runtime: Runtime, tools: list[ToolInfo], flags: list[str]) -> None:
79
+ def register_tools(
80
+ ctx: PluginContext,
81
+ runtime: Runtime,
82
+ tools: list[ToolInfo],
83
+ flags: list[str],
84
+ emoji: Mapping[str, str] | None = None,
85
+ ) -> None:
80
86
  """Register each listed tool under its own ID, grouped in one Hermes toolset per package toolset."""
87
+ toolset_emoji = TOOLSET_EMOJI if emoji is None else emoji
81
88
  for tool in tools:
82
89
  ctx.register_tool(
83
90
  name=tool.id,
84
91
  toolset=TOOLSET_PREFIX + tool.toolset.replace("_", "-"),
85
92
  schema={"name": tool.id, "description": tool.description, "parameters": tool.input_schema},
86
93
  handler=make_handler(runtime, tool.id, flags),
87
- emoji=TOOLSET_EMOJI.get(tool.toolset, ""),
94
+ emoji=toolset_emoji.get(tool.toolset, ""),
88
95
  description=tool.title,
89
96
  )