tonedial 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // Argument parsing, dispatch, and human/JSON output.
2
2
  import { parseArgs } from "node:util";
3
3
  import { isAbsolute } from "node:path";
4
- import { ConfigError, isConfigV2, nearest } from "./core/config.js";
4
+ import { ConfigError, SETTING_KEYS, isConfigV2, nearest } from "./core/config.js";
5
+ import { AXIS_ORDER } from "./core/anchors.js";
5
6
  import { compile } from "./core/compile.js";
6
7
  import { toCompileInput } from "./core/resolve.js";
7
8
  import { inspectContextLayout } from "./core/seeds.js";
8
9
  import { OperationError } from "./core/operations.js";
9
10
  import { RegionError } from "./wiring/region.js";
10
11
  import { connectionScopeFlag, installShape, resolveAgentPaths, resolvePaths } from "./core/paths.js";
11
- import { messages } from "./core/i18n.js";
12
+ import { catalogue, configLanguage, detectLanguage, messages } from "./core/i18n.js";
12
13
  import { isColourMode, palette } from "./ui/style.js";
13
14
  import { cells, maxCells, resolveBudget, wrap } from "./ui/width.js";
14
15
  import { interactive } from "./ui/prompt.js";
@@ -22,6 +23,7 @@ import { runSave } from "./commands/save.js";
22
23
  import { runCreate } from "./commands/create.js";
23
24
  import { runAddress, runPersona, runSet, runReset } from "./commands/set.js";
24
25
  import { runShow } from "./commands/show.js";
26
+ import { runGet, settingHelp } from "./commands/get.js";
25
27
  import { runUse } from "./commands/use.js";
26
28
  import { runDoctor } from "./commands/doctor.js";
27
29
  import { runEject } from "./commands/eject.js";
@@ -34,7 +36,7 @@ export const JSON_SCHEMA = 3;
34
36
  * run time is the one thing that breaks all three binary packagers at once. A test asserts this
35
37
  * equals the manifest, which is the cheap half of the trade.
36
38
  */
37
- export const VERSION = "0.1.2";
39
+ export const VERSION = "0.1.3";
38
40
  const FLAG_NAMES = ["project", "check", "explain", "global", "here", "migrateLegacy", "auto", "dryRun", "fix", "rollback", "fromOperation", "fromPreset", "restorePreset", "preset", "slug", "acceptUsage", "lang", "commands", "tools"];
39
41
  /** Command-line spelling for each flag, where it differs from the field name. */
40
42
  const FLAG_SPELLING = {
@@ -47,6 +49,7 @@ const FLAG_SPELLING = {
47
49
  /** Each command declares its accepted flags and positional limit; null means unlimited. */
48
50
  const COMMANDS = {
49
51
  set: { run: runSet, flags: [], max: null, context: "state" },
52
+ get: { run: runGet, flags: [], max: 1 },
50
53
  test: { run: runTest, flags: ["project", "dryRun", "preset", "acceptUsage"], max: 1 },
51
54
  address: { run: runAddress, flags: [], max: 2, context: "state" },
52
55
  persona: { run: runPersona, flags: [], max: 2, context: "state" },
@@ -77,7 +80,8 @@ export const COMMAND_NAMES = Object.keys(COMMANDS);
77
80
  /** Intent groups share one command inventory between compact and full help. */
78
81
  const COMMAND_GROUPS = [
79
82
  ["tone", [
80
- ["set", "set <axis>=<level> …"],
83
+ ["set", "set <key>=<value> …"],
84
+ ["get", "get [key]"],
81
85
  ["address", "address [self user]"],
82
86
  ["persona", "persona [agent user]"],
83
87
  ["reset", "reset [axis]"],
@@ -108,6 +112,7 @@ const COMMAND_GROUPS = [
108
112
  const COMMAND_SYNTAX = COMMAND_GROUPS.flatMap(([, rows]) => rows);
109
113
  const COMMAND_EXAMPLES = {
110
114
  set: ["tonedial set", "tonedial set warmth=5 critique=4", "tonedial set persona.agent=reviewer"],
115
+ get: ["tonedial get", "tonedial get language", "tonedial get speech_level --json"],
111
116
  test: ["tonedial test codex --dry-run", "tonedial test claude --project .", "tonedial test codex --preset my-tone --accept-usage"],
112
117
  address: ['tonedial address "assistant" "friend"'],
113
118
  persona: ["tonedial persona", 'tonedial persona "Fictional reviewer" "Developer"'],
@@ -216,7 +221,8 @@ function compactUsage(t, ui) {
216
221
  ].join("\n");
217
222
  }
218
223
  /** One command's syntax and only the flags that can affect that command. */
219
- function commandUsage(name, t, ui) {
224
+ function commandUsage(name, copy, ui) {
225
+ const t = copy.cli;
220
226
  const command = COMMANDS[name];
221
227
  const syntax = COMMAND_SYNTAX.find(([key]) => key === name)?.[1] ?? name;
222
228
  const flagKeys = [
@@ -231,6 +237,7 @@ function commandUsage(name, t, ui) {
231
237
  return [syntax, t.help.flags[key]];
232
238
  });
233
239
  const width = Math.max(...flags.map(([flag]) => cells(flag)));
240
+ const keyWidth = Math.max(...[...AXIS_ORDER, ...SETTING_KEYS].map(key => maxCells(key)));
234
241
  return [
235
242
  ui.bold(`tonedial ${syntax}`),
236
243
  "",
@@ -241,6 +248,12 @@ function commandUsage(name, t, ui) {
241
248
  "",
242
249
  ui.bold(t.help.examples),
243
250
  ...COMMAND_EXAMPLES[name].map(example => ` ${ui.accent(example)}`),
251
+ ...(["get", "set"].includes(name) ? [
252
+ "", ui.bold(t.artifact.whatEachAxisObserves),
253
+ ...helpRows(AXIS_ORDER.map(key => [key, settingHelp(key, copy).join(" · ")]), keyWidth, ui),
254
+ "", ui.bold(t.artifact.whatElseIsSettable),
255
+ ...helpRows(SETTING_KEYS.map(key => [key, settingHelp(key, copy).join(" · ")]), keyWidth, ui),
256
+ ] : []),
244
257
  ].join("\n");
245
258
  }
246
259
  /**
@@ -319,7 +332,8 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
319
332
  // Resolved before the mode is validated, so the message about a bad `--color` is itself
320
333
  // painted the way the rest of this invocation would have been.
321
334
  const configPath = resolvePaths(env).config;
322
- const say = messages(configPath, env);
335
+ const copy = catalogue(configLanguage(configPath) ?? detectLanguage(env));
336
+ const say = copy.cli;
323
337
  const colour = String(parsed.values.color);
324
338
  const mode = isColourMode(colour) ? colour : "auto";
325
339
  // The line budget is settled here beside the colour, because it is the same kind of decision:
@@ -353,7 +367,7 @@ export async function run(argv, env = process.env, cwd = process.cwd(), isTTY =
353
367
  if (!Object.hasOwn(COMMANDS, name)) {
354
368
  return fail(asJson, say.errors.notACommand(asJson ? name : humanText(name), COMMAND_NAMES, nearest(name, COMMAND_NAMES)), 2, uiErr);
355
369
  }
356
- process.stdout.write(`${commandUsage(name, say, ui)}\n`);
370
+ process.stdout.write(`${commandUsage(name, copy, ui)}\n`);
357
371
  return 0;
358
372
  }
359
373
  // npx supplies no durable bare command for generated controls. Help and version stay available.
@@ -503,7 +517,7 @@ function fail(asJson, message, code, ui) {
503
517
  }
504
518
  else {
505
519
  // A standalone path is a copy target, not prose to split into unusable fragments.
506
- const lines = message.split("\n").flatMap((line) => isAbsolute(line) || /^(?:tonedial|npm|curl) /.test(line) ? [humanText(line)] : wrap(humanText(line), ui.width));
520
+ const lines = message.split("\n").flatMap((line) => isAbsolute(line) || /^(?:tonedial|curl) |^npm (?:install|uninstall|update) -|^https?:\/\//.test(line) ? [humanText(line)] : wrap(humanText(line), ui.width));
507
521
  process.stderr.write(lines.map((line) => ui.error(line)).join("\n") + "\n");
508
522
  }
509
523
  return code;
@@ -0,0 +1,41 @@
1
+ // Read active settings without compiling, repairing, or interpreting stored text.
2
+ import { isAxis, AGE_BANDS, GENDERS, ConfigError, nearest, settingValues } from "../core/config.js";
3
+ import { LANGUAGES, catalogue, detectLanguage, isLanguage } from "../core/i18n.js";
4
+ import { humanText } from "../ui/format.js";
5
+ import { maxCells, padEnd, wrap } from "../ui/width.js";
6
+ import { planConfig } from "./compile.js";
7
+ /** The same descriptions and allowed tokens serve key reads and command help. */
8
+ export function settingHelp(key, copy) {
9
+ if (isAxis(key)) {
10
+ const hint = copy.axisHints[key];
11
+ return [hint.owns, `1–5 · 1 ${hint.low} · 5 ${hint.high}`];
12
+ }
13
+ const choices = key === "language" ? LANGUAGES : key === "persona.age" ? AGE_BANDS : key === "persona.gender" ? GENDERS : null;
14
+ return [copy.settingHints[key], ...(choices ? [choices.join(" | ")] : [])];
15
+ }
16
+ export function runGet(ctx, args) {
17
+ const { change, config } = planConfig(ctx);
18
+ if (change.before === null)
19
+ throw new ConfigError(ctx.t.test.missingConfig);
20
+ const all = settingValues(config);
21
+ const [requested] = args;
22
+ if (requested !== undefined && !Object.hasOwn(all, requested)) {
23
+ throw new ConfigError(ctx.t.axis.notSettable(ctx.asJson ? requested : humanText(requested), nearest(requested, Object.keys(all))));
24
+ }
25
+ const values = requested === undefined ? all : { [requested]: all[requested] };
26
+ const column = Math.max(...Object.keys(values).map(key => maxCells(key)));
27
+ const budget = ctx.ui.width - column - 4;
28
+ const lines = Object.entries(values).flatMap(([key, value]) => {
29
+ const text = value === null ? "—" : humanText(String(value));
30
+ if (budget < 12)
31
+ return [` ${ctx.ui.accent(key)}`, ...wrap(text, ctx.ui.width - 4).map(line => ` ${line}`)];
32
+ const [first, ...rest] = wrap(text, budget);
33
+ return [` ${ctx.ui.accent(padEnd(key, column))} ${first ?? ""}`,
34
+ ...rest.map(line => `${" ".repeat(column + 4)}${line}`)];
35
+ });
36
+ if (requested !== undefined) {
37
+ const copy = catalogue(isLanguage(config.language) ? config.language : detectLanguage(ctx.env));
38
+ lines.push("", ...settingHelp(requested, copy).flatMap(hint => wrap(hint, ctx.ui.width - 2).map(line => ` ${ctx.ui.dim(line)}`)));
39
+ }
40
+ return { lines, json: { values } };
41
+ }
@@ -60,7 +60,7 @@ export async function savePreset(ctx, displayName, state, options = {}) {
60
60
  [address?.self_ref ? ctx.t.persona.callsItself(humanText(address.self_ref)) : "",
61
61
  address?.user_ref ? ctx.t.persona.callsYou(humanText(address.user_ref)) : ""].filter(Boolean).join(" · "),
62
62
  ...["user", "agent"].map((field) => `${ctx.t.persona.descriptionFull(field, humanText(config.persona[field] ?? ""))} (${ctx.t.create.descriptionCount([...(config.persona[field] ?? "")].length, PERSONA_DESCRIPTION_MAX)})`),
63
- `${(config.safeword ? ctx.t.settings.safewordSet(humanText(config.safeword)) : ctx.t.settings.noSafeword).split(" — ")[0]} · ${ctx.t.settings.register(config.speech_level)}`,
63
+ `${config.safeword ? ctx.t.settings.safewordSet(humanText(config.safeword)) : ctx.t.settings.noSafeword} · ${ctx.t.settings.register(config.speech_level)}`,
64
64
  ];
65
65
  for (const detail of details.filter(Boolean))
66
66
  review.push(...wrap(detail, ctx.ui.width - 2).map((line) => ` ${line}`));
@@ -5,7 +5,7 @@
5
5
  // job is to say so. Every selected level remains unchanged, including contradictions.
6
6
  import { AXIS_DEFAULT, AXIS_ORDER, riskyAxes } from "../core/anchors.js";
7
7
  import { check as checkCombos, PERSONA_DESCRIPTION_MAX } from "../core/combos.js";
8
- import { AGE_BANDS, GENDERS, SPEECH_LEVELS, ConfigError, SETTING_KEYS, isAxis, nearest, nearestAxis, serializeConfig, } from "../core/config.js";
8
+ import { AGE_BANDS, GENDERS, SPEECH_LEVELS, ConfigError, SETTING_KEYS, isAxis, nearest, nearestAxis, serializeConfig, settingValues, } from "../core/config.js";
9
9
  import { toCompileInput } from "../core/resolve.js";
10
10
  import { LANGUAGES, catalogue, isLanguage } from "../core/i18n.js";
11
11
  import { presetSettings, resolvePreset } from "../core/presets.js";
@@ -157,33 +157,19 @@ function movementLines(moves, ctx, axes) {
157
157
  * Returns `undefined` for a key this tool does not have, which `assign` then rejects by name.
158
158
  */
159
159
  function readKey(config, key, t) {
160
- if (key === "safeword")
161
- return config.safeword ?? UNSET;
162
- if (key === "speech_level")
163
- return config.speech_level;
164
- if (key === "language")
165
- return config.language;
166
- if (key.startsWith("persona.")) {
167
- const field = key.slice("persona.".length);
168
- if (field === "age")
169
- return config.persona.age ?? UNSET;
170
- if (field === "gender")
171
- return config.persona.gender ?? UNSET;
172
- if (field === "self_ref")
173
- return config.persona.address?.self_ref ?? UNSET;
174
- if (field === "user_ref")
175
- return config.persona.address?.user_ref ?? UNSET;
176
- if (field === "note") {
177
- const note = config.persona.note;
178
- return note ? t.persona.noteChars([...note].length) : UNSET;
179
- }
180
- if (field === "user" || field === "agent") {
181
- const description = config.persona[field];
182
- return description ? t.persona.descriptionChars(field, [...description].length) : UNSET;
183
- }
160
+ const values = settingValues(config);
161
+ if (!Object.hasOwn(values, key))
184
162
  return undefined;
163
+ const value = values[key];
164
+ if (value === null)
165
+ return UNSET;
166
+ if (typeof value === "string") {
167
+ if (key === "persona.note")
168
+ return t.persona.noteChars([...value].length);
169
+ if (key === "persona.user" || key === "persona.agent")
170
+ return t.persona.descriptionChars(key.slice("persona.".length), [...value].length);
185
171
  }
186
- return isAxis(key) ? String(config.axes[key]) : undefined;
172
+ return String(value);
187
173
  }
188
174
  /**
189
175
  * Assign each pair, and record the movement each one actually made.
@@ -5,7 +5,7 @@ import { isAbsolute } from "node:path";
5
5
  import { stripVTControlCharacters } from "node:util";
6
6
  import { AXIS_DEFAULT, AXIS_GROUP, AXIS_KIND, AXIS_RISK_FROM, riskyAxes } from "../core/anchors.js";
7
7
  import { check as checkCombos, PERSONA_DESCRIPTION_MAX } from "../core/combos.js";
8
- import { ConfigError, isConfigV2 } from "../core/config.js";
8
+ import { ConfigError, isConfigV2, settingValues } from "../core/config.js";
9
9
  import { compile, measure } from "../core/compile.js";
10
10
  import { withPreset, matchesPreset, inspectPresetLibrary, resolvePreset } from "../core/presets.js";
11
11
  import { toCompileInput } from "../core/resolve.js";
@@ -31,6 +31,7 @@ export function runShow(ctx, args) {
31
31
  }
32
32
  const shown = preview ? withPreset(config, preview) : config;
33
33
  const axes = shown.axes;
34
+ const values = settingValues(shown);
34
35
  const input = toCompileInput(shown);
35
36
  const artifact = compile(input);
36
37
  const size = measure(artifact);
@@ -63,33 +64,37 @@ export function runShow(ctx, args) {
63
64
  for (const folded of wrap(sizeLabel, ui.width - 2))
64
65
  lines.push(` ${ui.dim(folded)}`);
65
66
  }
66
- const p = shown.persona;
67
- const personaSet = Boolean(p.age || p.gender || p.note || p.address || p.user || p.agent);
67
+ const { "persona.age": age, "persona.gender": gender, "persona.note": note, "persona.self_ref": selfRef, "persona.user_ref": userRef, "persona.user": user, "persona.agent": agent } = values;
68
+ const personaSet = Boolean(age || gender || note || selfRef || userRef || user || agent);
68
69
  const gauges = [gaugeHeader(ui, ctx.t), ...gaugeBlock(axes, ui)];
69
70
  const gaugeWidth = Math.max(...gauges.map((line) => maxCells(stripVTControlCharacters(line))));
70
71
  const detailWidth = ui.terminal && ui.width >= 119 ? ui.width - gaugeWidth - 4 : ui.width - 2;
71
72
  const details = [ui.bold(ctx.t.artifact.settingsHeading)];
72
73
  if (personaSet) {
73
74
  const bits = [
74
- p.age ? humanText(p.age) : null,
75
- p.gender ? humanText(p.gender) : null,
76
- p.address?.self_ref ? ctx.t.persona.callsItself(humanText(p.address.self_ref)) : null,
77
- p.address?.user_ref ? ctx.t.persona.callsYou(humanText(p.address.user_ref)) : null,
78
- p.note ? ctx.t.persona.noteChars([...p.note].length) : null,
79
- p.user && p.agent ? ctx.t.persona.descriptionCounts([...p.user].length, [...p.agent].length, PERSONA_DESCRIPTION_MAX) : null,
80
- p.user && !p.agent ? ctx.t.persona.descriptionChars("user", [...p.user].length) : null,
81
- p.agent && !p.user ? ctx.t.persona.descriptionChars("agent", [...p.agent].length) : null,
75
+ age ? humanText(age) : null,
76
+ gender ? humanText(gender) : null,
77
+ selfRef ? ctx.t.persona.callsItself(humanText(selfRef)) : null,
78
+ userRef ? ctx.t.persona.callsYou(humanText(userRef)) : null,
79
+ note ? ctx.t.persona.noteChars([...note].length) : null,
80
+ user && agent ? ctx.t.persona.descriptionCounts([...user].length, [...agent].length, PERSONA_DESCRIPTION_MAX) : null,
81
+ user && !agent ? ctx.t.persona.descriptionChars("user", [...user].length) : null,
82
+ agent && !user ? ctx.t.persona.descriptionChars("agent", [...agent].length) : null,
82
83
  ].filter(Boolean);
83
84
  if (bits.length)
84
85
  for (const folded of wrap(bits.join(" · "), detailWidth))
85
86
  details.push(ui.dim(folded));
86
- for (const [field, value] of [["agent", p.agent], ["user", p.user]]) {
87
+ for (const [field, value] of [["agent", agent], ["user", user]]) {
87
88
  if (value === null)
88
89
  continue;
89
90
  for (const folded of wrap(ctx.t.persona.descriptionFull(field, humanText(value)), detailWidth)) {
90
91
  details.push(ui.dim(folded));
91
92
  }
92
93
  }
94
+ if (note !== null) {
95
+ for (const folded of wrap(ctx.t.persona.descriptionFull("note", humanText(note)), detailWidth))
96
+ details.push(ui.dim(folded));
97
+ }
93
98
  }
94
99
  // Folded rather than laid out: the safeword is user text of unbounded length, so this row
95
100
  // can always exceed the budget and no amount of shortening the fixed words prevents it.
@@ -97,7 +102,7 @@ export function runShow(ctx, args) {
97
102
  ? [...new Set(Object.values(config.wiring.registrations).map((record) => `${record.target} (${record.scope})`))]
98
103
  : Object.keys(config.wiring);
99
104
  for (const text of [
100
- stateRow(shown, ctx.t), ctx.t.artifact.displaySettings(config.language),
105
+ stateRow(values, ctx.t), ctx.t.artifact.displaySettings(values.language),
101
106
  ctx.t.artifact.connectionRecords(records.map(humanText)),
102
107
  ])
103
108
  for (const folded of wrap(text, detailWidth))
@@ -170,10 +175,10 @@ export function runShow(ctx, args) {
170
175
  riskAxes: riskyAxes(axes),
171
176
  riskThresholds: AXIS_RISK_FROM,
172
177
  persona: shown.persona,
173
- safeword: shown.safeword,
174
- speech_level: shown.speech_level,
178
+ safeword: values.safeword,
179
+ speech_level: values.speech_level,
175
180
  // Interface language never changes the compiled tone.
176
- language: config.language,
181
+ language: values.language,
177
182
  chars: size.chars,
178
183
  artifact: { exists: artifactOnDisk },
179
184
  },
@@ -197,9 +202,9 @@ function titleRow(name, sizeLabel, ctx) {
197
202
  * Safeword and register on one row. Plain text, because it is folded before it is
198
203
  * painted and an escape sequence measures as characters while occupying no cell.
199
204
  */
200
- function stateRow(config, t) {
201
- const safeword = config.safeword ? t.settings.safewordSet(humanText(config.safeword)).split(" — ")[0] : t.settings.noSafeword.split(" — ")[0];
202
- return `${safeword} · ${t.settings.register(config.speech_level)}`;
205
+ function stateRow(values, t) {
206
+ const safeword = values.safeword ? t.settings.safewordSet(humanText(values.safeword)) : t.settings.noSafeword;
207
+ return `${safeword} · ${t.settings.register(values.speech_level)}`;
203
208
  }
204
209
  // Presence alone establishes neither current contents nor native loading.
205
210
  function artifactExists(ctx) {
@@ -164,6 +164,7 @@ export const SPEECH_LEVEL = {
164
164
  auto: "In languages with honorifics, choose address forms for the current relationship and scene; keep them consistent within each reply.",
165
165
  polite: "In languages with honorifics, use polite address forms even with familiar wording.",
166
166
  plain: "In languages with honorifics, use plain address forms even with formal wording.",
167
+ rough: "In languages with honorifics, use rough, cheeky non-honorific sentence endings; keep the configured address terms and the selected formality, warmth, profanity and playfulness levels.",
167
168
  };
168
169
  export const CONTROLS = {
169
170
  /**
@@ -35,8 +35,8 @@ function validate(config) {
35
35
  throw new RangeError(`axis ${axis} is ${level}, not a level 1 to 5`);
36
36
  }
37
37
  }
38
- if (!(config.speech_level in SPEECH_LEVEL)) {
39
- throw new RangeError(`speech_level is ${config.speech_level}, not auto, polite, or plain`);
38
+ if (!Object.hasOwn(SPEECH_LEVEL, config.speech_level)) {
39
+ throw new RangeError(`speech_level is ${config.speech_level}, not auto, polite, plain, or rough`);
40
40
  }
41
41
  if (config.persona !== null) {
42
42
  for (const field of ["user", "agent"]) {
@@ -10,7 +10,7 @@ export const CONFIG_SCHEMA = 2;
10
10
  export const LEGACY_CONFIG_SCHEMA = 1;
11
11
  export const AGE_BANDS = ["unspecified", "teens", "20s", "30s", "40s", "50s+"];
12
12
  export const GENDERS = ["unspecified", "masculine", "feminine", "neutral"];
13
- export const SPEECH_LEVELS = ["auto", "polite", "plain"];
13
+ export const SPEECH_LEVELS = ["auto", "polite", "plain", "rough"];
14
14
  export const WIRING_SCOPES = ["global", "project"];
15
15
  export const RESOURCE_KINDS = ["hook", "skill", "shell", "reader", "command"];
16
16
  export const RESOURCE_TYPES = ["file", "directory", "symlink"];
@@ -853,20 +853,32 @@ export const isAxis = (name) => AXIS_ORDER.includes(name);
853
853
  /**
854
854
  * Everything `set <key>=<value>` takes that is not an axis.
855
855
  *
856
- * Two consumers: the did-you-mean match in `set`, and the order `show --explain` renders the
857
- * per-language descriptions in. It does not drive `assign` or `readKey` — each of those branches
858
- * carries its own parsing and its own refusal.
856
+ * Shared by key discovery, setting reads, and the did-you-mean match in `set`.
857
+ * Assignment retains its own validation.
859
858
  *
860
- * Only one drift direction is caught. A key listed here with no branch in `assign` fails the
861
- * test that drives every entry through `set`. A branch added to `assign` and never listed here
862
- * is invisible: it would not appear in `--explain` and `nearest` would not suggest it, and
863
- * nothing fails. Closing that needs `assign` to be a table rather than a chain of `if`s.
859
+ * Command tests exercise this inventory through both setting and reading values.
864
860
  */
865
861
  export const SETTING_KEYS = [
866
862
  "safeword", "speech_level", "language",
867
863
  "persona.age", "persona.gender", "persona.self_ref", "persona.user_ref", "persona.note",
868
864
  "persona.user", "persona.agent",
869
865
  ];
866
+ /** Current editable values, without presentation labels or internal ownership metadata. */
867
+ export function settingValues(config) {
868
+ return {
869
+ ...Object.fromEntries(AXIS_ORDER.map(axis => [axis, config.axes[axis]])),
870
+ safeword: config.safeword,
871
+ speech_level: config.speech_level,
872
+ language: config.language,
873
+ "persona.age": config.persona.age,
874
+ "persona.gender": config.persona.gender,
875
+ "persona.self_ref": config.persona.address?.self_ref ?? null,
876
+ "persona.user_ref": config.persona.address?.user_ref ?? null,
877
+ "persona.note": config.persona.note,
878
+ "persona.user": config.persona.user,
879
+ "persona.agent": config.persona.agent,
880
+ };
881
+ }
870
882
  /** Levenshtein-lite: enough to say "did you mean critique?" and no more. */
871
883
  export function nearest(name, candidates) {
872
884
  const distance = (a, b) => {
package/dist/core/i18n.js CHANGED
@@ -41,7 +41,7 @@ const enMessages = {
41
41
  range: (axis, given) => `${axis}: expected 1–5 (got ${given})`,
42
42
  unknown: (name, nearest) => `${name}: not an axis${nearest ? `. Did you mean ${nearest}?` : ""}`,
43
43
  notSettable: (name, nearest) => `${name}: not an axis or setting. ${nearest ? `Did you mean ${nearest}? ` : ""}` +
44
- "Full list: tonedial show --explain",
44
+ "Full list: tonedial get --help",
45
45
  reset: (axis, level) => `${axis}: default ${level}`,
46
46
  resetAll: "All axes reset to defaults; persona and settings kept.",
47
47
  nothingChanged: "no changes",
@@ -98,7 +98,7 @@ const enMessages = {
98
98
  slug: "Slug (lowercase letters, digits, dashes)",
99
99
  user: "Your role or background (up to 200 characters)",
100
100
  agent: "Agent role (up to 200 characters)",
101
- register: "Register (auto, polite, plain)",
101
+ register: "Honorific forms (auto, polite, plain, rough)",
102
102
  safeword: "Safeword",
103
103
  needsName: "create needs a display name. Run tonedial create in a terminal, or supply a name and optional key=value settings with --auto.",
104
104
  exists: (slug) => `${slug}: a preset already exists. Choose another slug, or use save to update it.`,
@@ -156,16 +156,16 @@ const enMessages = {
156
156
  descriptionOverCap: (field, given, cap) => `${field}: ${given} characters · maximum ${cap}`,
157
157
  descriptionChars: (field, count) => `${field === "user" ? "user context" : "fictional agent role"} · ${count} chars`,
158
158
  descriptionCounts: (user, agent, cap) => `roles · user ${user}/${cap} · agent ${agent}/${cap}`,
159
- descriptionFull: (field, value) => `${field === "user" ? "user context" : "fictional agent role"}: ${value}`,
159
+ descriptionFull: (field, value) => `${field === "user" ? "user context" : field === "agent" ? "fictional agent role" : "persona note"}: ${value}`,
160
160
  },
161
161
  settings: {
162
162
  guardsRetired: "guards is retired. ToneDial no longer adds guard requests. No settings were changed.",
163
163
  fixedWidthRetired: "fixed_width is retired. Output now follows terminal width automatically. No settings were changed.",
164
164
  speechLevel: (options) => `speech_level · available: ${options.join(", ")}`,
165
165
  safewordShort: (phrase) => `safeword ${phrase}: too short and may trigger by accident`,
166
- safewordSet: (phrase) => `safeword ${phrase} — requests one plain answer when something reads wrong. It neither switches tone off nor grants authority`,
167
- noSafeword: "no safeword — request a plain answer through the skill only",
168
- register: (level) => `register ${level}`,
166
+ safewordSet: (phrase) => `safeword ${phrase}`,
167
+ noSafeword: "no safeword",
168
+ register: (level) => `register ${level} (${({ auto: "contextual", polite: "honorific", plain: "non-honorific", rough: "rough non-honorific" })[level]})`,
169
169
  },
170
170
  artifact: {
171
171
  size: (chars) => `TONE.md ${chars} characters`,
@@ -397,6 +397,7 @@ const enMessages = {
397
397
  npmScopeUnknown: "Cannot verify npm's global installation directory. Check npm prefix -g and npm root -g before retrying.",
398
398
  npmScopeMismatch: "This ToneDial copy is not the global package managed by the selected npm. Use the matching Node/npm installation; no update was run.",
399
399
  npmFailed: "npm update failed. Check npm's access and registry settings, then retry the displayed command for npm's diagnostics.",
400
+ npmPermissionDenied: (code, prefix) => `npm update needs write permission (${code}).\n${prefix}\nBack up your settings and presets, then reinstall in a user-owned directory.\nRecovery steps:\nhttps://github.com/awesometic/tonedial/blob/main/docs/USAGE.md#npm-permissions`,
400
401
  versionUnknown: "The installed ToneDial version could not be verified. Check the package installation before retrying.",
401
402
  stateIssue: {
402
403
  missing: "Saved settings are missing.",
@@ -431,6 +432,7 @@ const enMessages = {
431
432
  commands: {
432
433
  init: "set up and refresh bundled files",
433
434
  set: "change axes, role or settings · bare opens dial",
435
+ get: "read current setting values · key adds its meaning and choices",
434
436
  address: "set names for each party · bare prompts",
435
437
  persona: "set agent and user roles · bare prompts",
436
438
  reset: "reset an axis · bare resets all axes",
@@ -638,7 +640,7 @@ const en = {
638
640
  },
639
641
  settingHints: {
640
642
  safeword: "request for one plain answer to one message",
641
- speech_level: "honorific forms: auto, polite, plain · formality varies wording within them",
643
+ speech_level: "auto: adapt to the relationship · polite: honorific forms · plain: ordinary non-honorific forms · rough: rough, cheeky non-honorific endings · wording and distance: formality",
642
644
  language: "CLI interface language · unrelated to agent replies",
643
645
  "persona.age": "age band the model may associate with",
644
646
  "persona.gender": "gender the model may associate with",
@@ -688,7 +690,7 @@ const koMessages = {
688
690
  range: (axis, given) => `${axis}: 1~5만 쓸 수 있습니다(입력: ${given})`,
689
691
  unknown: (name, nearest) => `${name}: 축이 아닙니다${nearest ? `. ${nearest} 말씀이신가요?` : ""}`,
690
692
  notSettable: (name, nearest) => `${name}: 축도 설정도 아닙니다. ${nearest ? `${nearest} 말씀이신가요? ` : ""}` +
691
- "전체 목록: tonedial show --explain",
693
+ "전체 목록: tonedial get --help",
692
694
  reset: (axis, level) => `${axis}: 기본값 ${level}`,
693
695
  resetAll: "모든 축을 기본값으로 되돌렸습니다. 페르소나와 설정은 유지했습니다.",
694
696
  nothingChanged: "변경 없음",
@@ -745,7 +747,7 @@ const koMessages = {
745
747
  slug: "슬러그(영문 소문자·숫자·붙임표)",
746
748
  user: "사용자 역할·배경(최대 200자)",
747
749
  agent: "에이전트 역할(최대 200자)",
748
- register: "말씨(auto, polite, plain)",
750
+ register: "말씨(auto, polite, plain, rough)",
749
751
  safeword: "세이프워드",
750
752
  needsName: "create에는 표시 이름이 필요합니다. 터미널에서 tonedial create를 실행하거나, 이름과 필요한 키=값 설정을 --auto와 함께 지정하세요.",
751
753
  exists: (slug) => `${slug}: 이미 있는 프리셋입니다. 다른 슬러그를 쓰거나 save로 갱신하세요.`,
@@ -806,16 +808,16 @@ const koMessages = {
806
808
  descriptionOverCap: (field, given, cap) => `${field}: ${given}자 · 최대 ${cap}자`,
807
809
  descriptionChars: (field, count) => `${field === "user" ? "사용자 배경" : "가상 에이전트 역할"} · ${count}자`,
808
810
  descriptionCounts: (user, agent, cap) => `역할 · 사용자 ${user}/${cap} · 에이전트 ${agent}/${cap}`,
809
- descriptionFull: (field, value) => `${field === "user" ? "사용자 배경" : "가상 에이전트 역할"}: ${value}`,
811
+ descriptionFull: (field, value) => `${field === "user" ? "사용자 배경" : field === "agent" ? "가상 에이전트 역할" : "추가 말투·역할"}: ${value}`,
810
812
  },
811
813
  settings: {
812
814
  guardsRetired: "guards 설정은 더 이상 쓰지 않습니다. ToneDial은 가드 요청을 넣지 않습니다. 설정은 바꾸지 않았습니다.",
813
815
  fixedWidthRetired: "fixed_width 설정은 더 이상 쓰지 않습니다. 출력은 터미널 폭에 자동으로 맞춥니다. 설정은 바꾸지 않았습니다.",
814
816
  speechLevel: (options) => `speech_level · 사용 가능: ${options.join(", ")}`,
815
817
  safewordShort: (phrase) => `세이프워드 ${phrase}: 너무 짧아 실수로 작동할 수 있습니다`,
816
- safewordSet: (phrase) => `세이프워드 ${phrase} — 이상할 때 한 메시지만 담백하게 답해 달라는 요청입니다. 톤을 끄거나 권한을 주지는 않습니다`,
817
- noSafeword: "세이프워드 없음 — 담백한 답은 스킬로만 요청할 수 있습니다",
818
- register: (level) => `말씨 ${level}`,
818
+ safewordSet: (phrase) => `세이프워드 ${phrase}`,
819
+ noSafeword: "세이프워드 없음",
820
+ register: (level) => `말씨 ${level} (${({ auto: "관계에 맞게 선택", polite: "존댓말", plain: "일반 반말", rough: "거친 반말" })[level]})`,
819
821
  },
820
822
  artifact: {
821
823
  size: (chars) => `TONE.md ${chars}자`,
@@ -1047,6 +1049,7 @@ const koMessages = {
1047
1049
  npmScopeUnknown: "npm의 전역 설치 경로를 확인할 수 없습니다. npm prefix -g와 npm root -g를 확인한 뒤 다시 시도해 주세요.",
1048
1050
  npmScopeMismatch: "현재 ToneDial은 선택한 npm이 관리하는 전역 패키지가 아닙니다. 설치할 때 사용한 Node/npm 환경을 선택해 주세요. 업데이트는 실행하지 않았습니다.",
1049
1051
  npmFailed: "npm 업데이트에 실패했습니다. npm의 접근 권한과 레지스트리 설정을 확인해 주세요. 표시된 명령을 직접 실행하면 npm의 오류 내용을 볼 수 있습니다.",
1052
+ npmPermissionDenied: (code, prefix) => `npm 업데이트에 필요한 쓰기 권한이 없습니다 (${code}).\n${prefix}\n설정과 프리셋을 백업한 뒤 사용자 소유 경로에 다시 설치해 주세요.\n복구 절차:\nhttps://github.com/awesometic/tonedial/blob/main/docs/USAGE.md#npm-permissions`,
1050
1053
  versionUnknown: "설치된 ToneDial 버전을 확인할 수 없습니다. 패키지 설치 상태를 확인한 뒤 다시 시도해 주세요.",
1051
1054
  stateIssue: {
1052
1055
  missing: "저장된 설정이 없습니다.",
@@ -1081,6 +1084,7 @@ const koMessages = {
1081
1084
  commands: {
1082
1085
  init: "초기 설정과 기본 파일 갱신",
1083
1086
  set: "축·역할·설정 변경 · 인자 없으면 다이얼",
1087
+ get: "현재 설정값 조회 · 키를 지정하면 의미와 선택지도 표시",
1084
1088
  address: "서로 부르는 말 · 인자 없으면 질문",
1085
1089
  persona: "에이전트·사용자 역할 · 인자 없으면 질문",
1086
1090
  reset: "축을 기본값으로 · 축을 안 주면 전부",
@@ -1292,7 +1296,7 @@ const ko = {
1292
1296
  },
1293
1297
  settingHints: {
1294
1298
  safeword: "한 메시지만 담백하게 답해 달라는 요청 문구",
1295
- speech_level: "높임 체계: auto, polite, plain · 표현의 격식은 formality로 조절",
1299
+ speech_level: "auto: 관계에 맞게 선택 · polite: 존댓말 · plain: 일반 반말 · rough: 거친 반말 · 표현의 격식과 거리감은 formality로 조절",
1296
1300
  language: "CLI 인터페이스 언어 · 에이전트 답변 언어와 무관",
1297
1301
  "persona.age": "모델이 연상할 나이대",
1298
1302
  "persona.gender": "모델이 연상할 성별",
@@ -289,8 +289,10 @@ export function recoverLegacyTone(text, t) {
289
289
  if (styleMarker && (Number(styleMarker[1]) !== config.axes.verbosity || Number(styleMarker[2]) !== config.axes.care))
290
290
  return null;
291
291
  const speech = rules.at(-1);
292
- config.speech_level = speech === `- ${speechLevels.polite}` ? "polite"
293
- : speech === `- ${speechLevels.plain}` ? "plain" : "auto";
292
+ config.speech_level = anchors === ANCHORS && styleMarker !== null && speech === `- ${SPEECH_LEVEL.rough}` ? "rough"
293
+ : speech === `- ${speechLevels.polite}` ? "polite"
294
+ : speech === `- ${speechLevels.plain}` ? "plain" : "auto";
295
+ const speechRule = config.speech_level === "rough" ? SPEECH_LEVEL.rough : speechLevels[config.speech_level];
294
296
  const expectedRules = [
295
297
  ...AXIS_ORDER.map((axis) => `- ${anchors[axis][config.axes[axis] - 1]}`),
296
298
  ...(historical ? [
@@ -300,7 +302,7 @@ export function recoverLegacyTone(text, t) {
300
302
  ? ["Grumble without being passive; make the complaint itself clear."] : []),
301
303
  ] : composites(config.axes)).map((line) => `- ${line}`),
302
304
  ...(historical ? [] : [`- ${FORMAT_COMPOSITION}`, `- ${anchors === ANCHORS ? REGISTER_COMPOSITION : PREVIOUS_REGISTER_COMPOSITION}`]),
303
- ...(speechLevels[config.speech_level] === null ? [] : [`- ${speechLevels[config.speech_level]}`]),
305
+ ...(speechRule === null ? [] : [`- ${speechRule}`]),
304
306
  ];
305
307
  const legacyExpectedRules = [
306
308
  ...AXIS_ORDER.map((axis) => `- ${LEGACY_CURRENT_ANCHORS[axis][config.axes[axis] - 1]}`),
@@ -308,7 +310,7 @@ export function recoverLegacyTone(text, t) {
308
310
  ? config.axes.warmth >= 4 && config.axes.profanity >= 4
309
311
  : config.axes.compliance <= 2 && config.axes.conviction <= 2).map((line) => `- ${line}`),
310
312
  `- ${PREVIOUS_REGISTER_COMPOSITION}`,
311
- ...(speechLevels[config.speech_level] === null ? [] : [`- ${speechLevels[config.speech_level]}`]),
313
+ ...(speechRule === null ? [] : [`- ${speechRule}`]),
312
314
  ];
313
315
  const acceptedRules = historical || styleMarker ? [expectedRules] : [expectedRules, legacyExpectedRules];
314
316
  if (!acceptedRules.some((candidate) => rules.length === candidate.length &&
@@ -30,7 +30,7 @@ const messages = {
30
30
  range: (axis, given) => `${axis}: se esperaba 1–5 (se recibió ${given})`,
31
31
  unknown: (name, nearest) => `${name}: no es un eje${nearest ? `. ¿Querías decir ${nearest}?` : ""}`,
32
32
  notSettable: (name, nearest) => `${name}: no es un eje ni un ajuste. ${nearest ? `¿Querías decir ${nearest}? ` : ""}` +
33
- "Lista completa: tonedial show --explain",
33
+ "Lista completa: tonedial get --help",
34
34
  reset: (axis, level) => `${axis}: valor predeterminado ${level}`,
35
35
  resetAll: "Se restablecieron todos los ejes; se conservaron la personalidad y los ajustes.",
36
36
  nothingChanged: "sin cambios",
@@ -87,7 +87,7 @@ const messages = {
87
87
  slug: "Identificador (minúsculas, dígitos y guiones)",
88
88
  user: "Tu rol o contexto (hasta 200 caracteres)",
89
89
  agent: "Rol del agente (hasta 200 caracteres)",
90
- register: "Registro (auto, polite, plain)",
90
+ register: "Registro (auto, polite, plain, rough)",
91
91
  safeword: "Palabra clave",
92
92
  needsName: "create necesita un nombre visible. Ejecuta tonedial create en un terminal o indica un nombre y ajustes key=value opcionales con --auto.",
93
93
  exists: (slug) => `${slug}: ya existe un preajuste. Elige otro identificador o actualízalo con save.`,
@@ -145,16 +145,16 @@ const messages = {
145
145
  descriptionOverCap: (field, given, cap) => `${field}: ${given} caracteres · máximo ${cap}`,
146
146
  descriptionChars: (field, count) => `${field === "user" ? "contexto del usuario" : "rol ficticio del agente"} · ${count} caracteres`,
147
147
  descriptionCounts: (user, agent, cap) => `Roles · usuario ${user}/${cap} · agente ${agent}/${cap}`,
148
- descriptionFull: (field, value) => `${field === "user" ? "contexto del usuario" : "rol ficticio del agente"}: ${value}`,
148
+ descriptionFull: (field, value) => `${field === "user" ? "contexto del usuario" : field === "agent" ? "rol ficticio del agente" : "nota de personalidad"}: ${value}`,
149
149
  },
150
150
  settings: {
151
151
  guardsRetired: "guards se ha retirado. ToneDial ya no añade solicitudes de protección. No se cambió ningún ajuste.",
152
152
  fixedWidthRetired: "fixed_width se ha retirado. La salida se adapta al ancho del terminal. No se cambió ningún ajuste.",
153
153
  speechLevel: (options) => `speech_level · disponibles: ${options.join(", ")}`,
154
154
  safewordShort: (phrase) => `Palabra clave ${phrase}: demasiado corta; podría activarse por accidente`,
155
- safewordSet: (phrase) => `Palabra clave ${phrase}: pide una respuesta sencilla si algo suena mal. No desactiva el tono ni concede autoridad`,
156
- noSafeword: "sin palabra clave: solo se puede pedir una respuesta sencilla mediante la habilidad",
157
- register: (level) => `registro ${level}`,
155
+ safewordSet: (phrase) => `Palabra clave ${phrase}`,
156
+ noSafeword: "sin palabra clave",
157
+ register: (level) => `registro ${level} (${({ auto: "según la relación", polite: "formas honoríficas", plain: "formas normales sin honoríficos", rough: "formas bruscas sin honoríficos" })[level]})`,
158
158
  },
159
159
  artifact: {
160
160
  size: (chars) => `TONE.md ${chars} caracteres`,
@@ -382,6 +382,7 @@ const messages = {
382
382
  npmScopeUnknown: "No se puede verificar el directorio global de npm. Comprueba npm prefix -g y npm root -g antes de reintentar.",
383
383
  npmScopeMismatch: "Esta copia de ToneDial no es el paquete global gestionado por el npm seleccionado. Usa la instalación de Node/npm correspondiente. No se ejecutó la actualización.",
384
384
  npmFailed: "La actualización de npm falló. Comprueba los permisos y el registro, y ejecuta el comando mostrado para ver el diagnóstico de npm.",
385
+ npmPermissionDenied: (code, prefix) => `La actualización de npm necesita permiso de escritura (${code}).\n${prefix}\nHaz una copia de seguridad de los ajustes y preajustes, y reinstala en un directorio de tu usuario.\nPasos de recuperación:\nhttps://github.com/awesometic/tonedial/blob/main/docs/USAGE.md#npm-permissions`,
385
386
  versionUnknown: "No se pudo verificar la versión instalada de ToneDial. Comprueba la instalación del paquete antes de reintentar.",
386
387
  stateIssue: {
387
388
  missing: "Faltan los ajustes guardados.",
@@ -416,6 +417,7 @@ const messages = {
416
417
  commands: {
417
418
  init: "configurar y actualizar archivos incluidos",
418
419
  set: "cambiar ejes, rol o ajustes · sin argumentos abre el dial",
420
+ get: "consultar valores actuales · una clave muestra su significado y opciones",
419
421
  address: "definir los nombres de ambas partes · sin argumentos pregunta",
420
422
  persona: "definir roles del agente y usuario · sin argumentos pregunta",
421
423
  reset: "restablecer un eje · sin argumentos restablece todos",
@@ -621,7 +623,7 @@ export const es = {
621
623
  },
622
624
  settingHints: {
623
625
  safeword: "petición de una respuesta sencilla para un solo mensaje",
624
- speech_level: "formas de trato: auto, polite, plain · formality varía el lenguaje dentro de ellas",
626
+ speech_level: "auto: según la relación · polite: formas honoríficas · plain: formas normales sin honoríficos · rough: terminaciones bruscas y descaradas sin honoríficos · formality controla el lenguaje y la distancia",
625
627
  language: "idioma de la interfaz CLI · independiente de las respuestas del agente",
626
628
  "persona.age": "franja de edad que puede asociar el modelo",
627
629
  "persona.gender": "género que puede asociar el modelo",
@@ -30,7 +30,7 @@ const messages = {
30
30
  range: (axis, given) => `${axis}: 1–5を指定してください(入力:${given})`,
31
31
  unknown: (name, nearest) => `${name}: 軸ではありません${nearest ? `。${nearest} のことですか?` : ""}`,
32
32
  notSettable: (name, nearest) => `${name}: 軸でも設定項目でもありません。${nearest ? `${nearest} のことですか? ` : ""}` +
33
- "全項目:tonedial show --explain",
33
+ "全項目:tonedial get --help",
34
34
  reset: (axis, level) => `${axis}: 既定値 ${level}`,
35
35
  resetAll: "全軸を既定値に戻しました。ペルソナと設定は保持しました。",
36
36
  nothingChanged: "変更なし",
@@ -87,7 +87,7 @@ const messages = {
87
87
  slug: "スラッグ(英小文字・数字・ハイフン)",
88
88
  user: "あなたの役割や背景(200文字まで)",
89
89
  agent: "エージェントの役割(200文字まで)",
90
- register: "敬語区分(auto, polite, plain)",
90
+ register: "敬語区分(auto, polite, plain, rough)",
91
91
  safeword: "合言葉",
92
92
  needsName: "create には表示名が必要です。ターミナルで tonedial create を実行するか、名前と任意の key=value 設定を --auto とともに指定してください。",
93
93
  exists: (slug) => `${slug}: 既に存在します。別のスラッグを選ぶか、save で更新してください。`,
@@ -145,16 +145,16 @@ const messages = {
145
145
  descriptionOverCap: (field, given, cap) => `${field}: ${given}文字 · 上限 ${cap}`,
146
146
  descriptionChars: (field, count) => `${field === "user" ? "ユーザー背景" : "架空の役割"} · ${count}文字`,
147
147
  descriptionCounts: (user, agent, cap) => `役割 · ユーザー ${user}/${cap} · エージェント ${agent}/${cap}`,
148
- descriptionFull: (field, value) => `${field === "user" ? "ユーザー背景" : "架空の役割"}: ${value}`,
148
+ descriptionFull: (field, value) => `${field === "user" ? "ユーザー背景" : field === "agent" ? "架空の役割" : "追加の話し方・役割"}: ${value}`,
149
149
  },
150
150
  settings: {
151
151
  guardsRetired: "guards は廃止されました。ToneDialはガード要求を追加しません。設定は変更していません。",
152
152
  fixedWidthRetired: "fixed_width は廃止されました。出力はターミナル幅に自動追従します。設定は変更していません。",
153
153
  speechLevel: (options) => `speech_level · 選択肢:${options.join(", ")}`,
154
154
  safewordShort: (phrase) => `合言葉 ${phrase}: 短すぎるため、意図せず反応する可能性があります`,
155
- safewordSet: (phrase) => `合言葉 ${phrase} — 違和感があるときに一度だけ平易な回答を求めます。口調の無効化や権限の付与はしません`,
156
- noSafeword: "合言葉なし — 平易な回答はスキルからのみ要求できます",
157
- register: (level) => `敬語区分 ${level}`,
155
+ safewordSet: (phrase) => `合言葉 ${phrase}`,
156
+ noSafeword: "合言葉なし",
157
+ register: (level) => `敬語区分 ${level} (${({ auto: "関係に合わせる", polite: "敬語", plain: "通常の常体", rough: "荒っぽい常体" })[level]})`,
158
158
  },
159
159
  artifact: {
160
160
  size: (chars) => `TONE.md ${chars}文字`,
@@ -382,6 +382,7 @@ const messages = {
382
382
  npmScopeUnknown: "npmのグローバルインストール先を確認できません。npm prefix -g と npm root -g を確認して再試行してください。",
383
383
  npmScopeMismatch: "このToneDialは選択したnpmが管理するグローバルパッケージではありません。対応するNode/npm環境を使用してください。更新は実行していません。",
384
384
  npmFailed: "npmの更新に失敗しました。権限とレジストリ設定を確認し、表示されたコマンドを実行してnpmの診断を確認してください。",
385
+ npmPermissionDenied: (code, prefix) => `npmの更新に必要な書き込み権限がありません (${code})。\n${prefix}\n設定とプリセットをバックアップしてから、ユーザー所有の場所に再インストールしてください。\n復旧手順:\nhttps://github.com/awesometic/tonedial/blob/main/docs/USAGE.md#npm-permissions`,
385
386
  versionUnknown: "インストールされたToneDialのバージョンを確認できません。パッケージの状態を確認して再試行してください。",
386
387
  stateIssue: {
387
388
  missing: "保存済み設定がありません。",
@@ -416,6 +417,7 @@ const messages = {
416
417
  commands: {
417
418
  init: "初期設定と同梱ファイルの更新",
418
419
  set: "軸・役割・設定を変更 · 引数なしでダイヤル",
420
+ get: "現在の設定値を確認 · キー指定で意味と選択肢も表示",
419
421
  address: "双方の呼称を設定 · 引数なしで質問",
420
422
  persona: "エージェントとユーザーの役割を設定 · 引数なしで質問",
421
423
  reset: "軸を初期化 · 引数なしで全軸",
@@ -621,7 +623,7 @@ export const ja = {
621
623
  },
622
624
  settingHints: {
623
625
  safeword: "1つのメッセージに一度だけ平易な回答を求める文句",
624
- speech_level: "敬語区分:auto, polite, plain · 各区分内の表現は formality で調整",
626
+ speech_level: "auto: 関係に合わせる · polite: 敬語 · plain: 通常の常体 · rough: 荒っぽく生意気な常体 · 表現の格式と距離感は formality で調整",
625
627
  language: "CLIの表示言語 · エージェントの回答言語とは別",
626
628
  "persona.age": "モデルに連想させる年齢層",
627
629
  "persona.gender": "モデルに連想させるジェンダー",
@@ -30,7 +30,7 @@ const messages = {
30
30
  range: (axis, given) => `${axis}:应为 1–5(收到 ${given})`,
31
31
  unknown: (name, nearest) => `${name}:不是语气轴${nearest ? `。是否想输入 ${nearest}?` : ""}`,
32
32
  notSettable: (name, nearest) => `${name}:不是语气轴或设置项。${nearest ? `是否想输入 ${nearest}? ` : ""}` +
33
- "完整列表:tonedial show --explain",
33
+ "完整列表:tonedial get --help",
34
34
  reset: (axis, level) => `${axis}:默认值 ${level}`,
35
35
  resetAll: "已将所有语气轴重置为默认值,保留人设和其他设置。",
36
36
  nothingChanged: "无变更",
@@ -87,7 +87,7 @@ const messages = {
87
87
  slug: "标识名(小写字母、数字、连字符)",
88
88
  user: "你的角色或背景(最多 200 字符)",
89
89
  agent: "代理角色(最多 200 字符)",
90
- register: "敬语形式(auto, polite, plain)",
90
+ register: "敬语形式(auto, polite, plain, rough)",
91
91
  safeword: "安全词",
92
92
  needsName: "create 需要显示名称。请在终端运行 tonedial create,或提供名称、可选的 key=value 设置及 --auto。",
93
93
  exists: (slug) => `${slug}:预设已存在。请选择其他标识名,或用 save 更新。`,
@@ -145,16 +145,16 @@ const messages = {
145
145
  descriptionOverCap: (field, given, cap) => `${field}:${given} 字符 · 上限 ${cap}`,
146
146
  descriptionChars: (field, count) => `${field === "user" ? "用户背景" : "虚构代理角色"} · ${count} 字符`,
147
147
  descriptionCounts: (user, agent, cap) => `角色 · 用户 ${user}/${cap} · 代理 ${agent}/${cap}`,
148
- descriptionFull: (field, value) => `${field === "user" ? "用户背景" : "虚构代理角色"}:${value}`,
148
+ descriptionFull: (field, value) => `${field === "user" ? "用户背景" : field === "agent" ? "虚构代理角色" : "补充语气与角色"}:${value}`,
149
149
  },
150
150
  settings: {
151
151
  guardsRetired: "guards 已停用,ToneDial 不再添加防护请求。设置未变更。",
152
152
  fixedWidthRetired: "fixed_width 已停用,输出会自动跟随终端宽度。设置未变更。",
153
153
  speechLevel: (options) => `speech_level · 可选:${options.join(", ")}`,
154
154
  safewordShort: (phrase) => `安全词 ${phrase}:太短,可能误触发`,
155
- safewordSet: (phrase) => `安全词 ${phrase} — 在感觉不对时请求一次平实回答,不会关闭语气或授予权限`,
156
- noSafeword: "未设置安全词 — 仍可通过技能请求平实回答",
157
- register: (level) => `敬语形式 ${level}`,
155
+ safewordSet: (phrase) => `安全词 ${phrase}`,
156
+ noSafeword: "未设置安全词",
157
+ register: (level) => `敬语形式 ${level} (${({ auto: "按关系选择", polite: "敬语", plain: "普通非敬语", rough: "粗鲁的非敬语" })[level]})`,
158
158
  },
159
159
  artifact: {
160
160
  size: (chars) => `TONE.md ${chars} 字符`,
@@ -382,6 +382,7 @@ const messages = {
382
382
  npmScopeUnknown: "无法确认 npm 的全局安装目录。请检查 npm prefix -g 和 npm root -g 后重试。",
383
383
  npmScopeMismatch: "当前 ToneDial 不是所选 npm 管理的全局软件包。请使用对应的 Node/npm 环境。未执行更新。",
384
384
  npmFailed: "npm 更新失败。请检查权限和镜像设置,并直接运行显示的命令查看 npm 的诊断信息。",
385
+ npmPermissionDenied: (code, prefix) => `npm 更新缺少写入权限 (${code})。\n${prefix}\n请先备份设置和预设,再重新安装到用户拥有的目录。\n恢复步骤:\nhttps://github.com/awesometic/tonedial/blob/main/docs/USAGE.md#npm-permissions`,
385
386
  versionUnknown: "无法确认安装的 ToneDial 版本。请检查软件包的安装状态后重试。",
386
387
  stateIssue: {
387
388
  missing: "保存的设置缺失。",
@@ -416,6 +417,7 @@ const messages = {
416
417
  commands: {
417
418
  init: "初始化并刷新内置文件",
418
419
  set: "修改语气轴、角色或设置 · 无参数打开面板",
420
+ get: "读取当前设置值 · 指定键可查看含义和选项",
419
421
  address: "设置双方称呼 · 无参数则交互输入",
420
422
  persona: "设置代理和用户角色 · 无参数则交互输入",
421
423
  reset: "重置语气轴 · 无参数重置全部",
@@ -621,7 +623,7 @@ export const zh = {
621
623
  },
622
624
  settingHints: {
623
625
  safeword: "对一条消息请求一次平实回答的短语",
624
- speech_level: "敬语形式:auto、polite、plain · formality 调整形式内措辞",
626
+ speech_level: "auto:按关系选择 · polite:敬语 · plain:普通非敬语 · rough:粗鲁且带有不逊语气的非敬语 · 措辞和距离感由 formality 调整",
625
627
  language: "CLI 界面语言 · 与代理回答语言无关",
626
628
  "persona.age": "模型可能联想的年龄段",
627
629
  "persona.gender": "模型可能联想的性别",
@@ -11,7 +11,7 @@ import { captureRawEntry, caseRenameMatchesCurrent, isNoop, planChange, planEntr
11
11
  import { BUNDLED, builtinPresetDir, LEGACY_BUILTIN_SLUGS, RETIRED_PRESETS } from "./presets.js";
12
12
  import { SKILL_MD, SKILL_NAME, SKILL_TRAITS_MD } from "./skill.js";
13
13
  // Bump for changed generated content; freshness also checks each managed seed's bytes/provenance.
14
- export const SEED_VERSION = 17;
14
+ export const SEED_VERSION = 19;
15
15
  /** The state-root operating boundaries. The compiler owns the current tone separately. */
16
16
  export const GUIDE_MD = `${GUIDE_FORMAT_MARKER}
17
17
  ToneDial changes manner only; it never expands task scope or permissions.
@@ -73,10 +73,10 @@ export function seedFiles(paths, selection) {
73
73
  };
74
74
  const native = [
75
75
  reader,
76
- { id: "skill/body", version: 17, path: join(paths.skill, SKILL_NAME, "SKILL.md"), label: `skill/${SKILL_NAME}/SKILL.md`, contents: SKILL_MD },
76
+ { id: "skill/body", version: 19, path: join(paths.skill, SKILL_NAME, "SKILL.md"), label: `skill/${SKILL_NAME}/SKILL.md`, contents: SKILL_MD },
77
77
  {
78
78
  id: "skill/traits",
79
- version: 9,
79
+ version: 11,
80
80
  path: join(paths.skill, SKILL_NAME, "references", "traits.md"),
81
81
  label: `skill/${SKILL_NAME}/references/traits.md`,
82
82
  contents: SKILL_TRAITS_MD,
@@ -1,9 +1,9 @@
1
1
  // Bounded release download and extraction; the candidate installer owns package replacement.
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
- import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
- import { isAbsolute, join, resolve } from "node:path";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { gunzipSync } from "node:zlib";
9
9
  import { ConfigError } from "./config.js";
@@ -47,6 +47,7 @@ export function inspectNpmInstall(env, t) {
47
47
  };
48
48
  const prefix = query(["prefix", "-g"]);
49
49
  const root = join(query(["root", "-g", "--prefix", prefix]), "tonedial");
50
+ let install;
50
51
  try {
51
52
  const own = fileURLToPath(new URL("../../", import.meta.url));
52
53
  const current = fileIdentity(own);
@@ -55,11 +56,22 @@ export function inspectNpmInstall(env, t) {
55
56
  if (lstatSync(root, { throwIfNoEntry: false })?.isSymbolicLink() || current === null || target === null || current.dev !== target.dev || current.ino !== target.ino || version === null) {
56
57
  throw new ConfigError(t.update.npmScopeMismatch);
57
58
  }
58
- return { entry, prefix, root, version };
59
+ install = { entry, prefix, root, version };
59
60
  }
60
61
  catch {
61
62
  throw new ConfigError(t.update.npmScopeMismatch);
62
63
  }
64
+ try {
65
+ // npm replaces the package by renaming it in this parent, not by editing its files.
66
+ accessSync(dirname(root), constants.W_OK | constants.X_OK);
67
+ }
68
+ catch (cause) {
69
+ const code = cause.code;
70
+ if (code === "EACCES" || code === "EPERM")
71
+ throw new ConfigError(t.update.npmPermissionDenied(code, prefix));
72
+ throw new ConfigError(t.update.npmScopeUnknown);
73
+ }
74
+ return install;
63
75
  }
64
76
  /** npm owns replacement; captured output cannot corrupt JSON or disclose npm configuration. */
65
77
  export function updateNpmInstall(install, env, t) {
@@ -67,8 +79,17 @@ export function updateNpmInstall(install, env, t) {
67
79
  if (current.entry !== install.entry || current.prefix !== install.prefix || current.root !== install.root || current.version !== install.version)
68
80
  throw new ConfigError(t.update.npmScopeMismatch);
69
81
  const result = spawnSync(install.entry, ["update", "-g", "tonedial", "--prefix", install.prefix, "--json"], { env, encoding: "utf8", timeout: 120_000, maxBuffer: RELEASE_MAX_BYTES, stdio: ["ignore", "pipe", "pipe"] });
70
- if (result.status !== 0)
82
+ if (result.status !== 0) {
83
+ let code;
84
+ try {
85
+ code = JSON.parse(result.stdout ?? "null")?.error?.code;
86
+ }
87
+ catch { /* npm may fail before JSON output. */ }
88
+ code ??= /^npm (?:error|ERR!) code (EACCES|EPERM)\s*$/m.exec(result.stderr ?? "")?.[1];
89
+ if (code === "EACCES" || code === "EPERM")
90
+ throw new ConfigError(t.update.npmPermissionDenied(code, install.prefix));
71
91
  throw new ConfigError(t.update.npmFailed);
92
+ }
72
93
  const version = installedVersion({ lib: install.root });
73
94
  if (version === null)
74
95
  throw new ConfigError(t.update.versionUnknown);
@@ -29,7 +29,14 @@ or repair decision. Never edit or recreate them directly.
29
29
  /** Both entry points distinguish current tone settings from saved profiles. */
30
30
  const PRESETS_MD = `## Current settings and saved presets
31
31
 
32
- \`tonedial show --json\` reports the current settings. \`show <slug> --json\` previews that
32
+ \`tonedial get --json\` reports the current editable settings in a flat \`values\` object.
33
+ \`get <key> --json\` selects one of those keys, preserving numbers, literal text and nulls.
34
+ The keys are the twelve axes, \`language\`, \`speech_level\`, \`safeword\`, and
35
+ \`persona.age\`, \`persona.gender\`, \`persona.self_ref\`, \`persona.user_ref\`,
36
+ \`persona.note\`, \`persona.user\`, and \`persona.agent\`.
37
+ \`language\` selects the CLI interface, not the agent's reply language.
38
+ Read-only \`get\` neither changes settings nor reloads this conversation.
39
+ \`tonedial show --json\` adds the overview and artifact metadata. \`show <slug> --json\` previews that
33
40
  preset's saved tone settings. \`unsaved\` on the current result means the current tone settings
34
41
  have not been saved back to a profile; the current configuration itself is persisted.
35
42
  For an explicit request to save a profile, use \`tonedial save "<display name>" --json\`.
@@ -107,6 +114,7 @@ interpolating it into shell code.
107
114
  | Persona manner or role note | \`tonedial set "persona.note=Patient guide" --json\` |
108
115
  | Safeword phrase | \`tonedial set "safeword=plain answer please" --json\` |
109
116
  | Honorific register system | \`tonedial set speech_level=polite --json\` |
117
+ | Rough, cheeky non-honorific endings | \`tonedial set speech_level=rough --json\` |
110
118
 
111
119
  Age bands are \`unspecified\`, \`teens\`, \`20s\`, \`30s\`, \`40s\`, and \`50s+\`.
112
120
  Gender values are \`unspecified\`, \`masculine\`, \`feminine\`, and \`neutral\`.
@@ -127,9 +135,11 @@ ToneDial has no guard toggle or automatic emergency register. All axis levels 1
127
135
  the user can request. Report advisory \`riskAxes\` and combination notices without lowering
128
136
  values, blocking the change, or asking for additional tone-risk confirmation. The host's task
129
137
  and permission rules still apply independently.
130
- For \`speech_level\`, choose \`polite\`, \`plain\`, or \`auto\` only when the user requests that
138
+ For \`speech_level\`, choose \`auto\`, \`polite\`, \`plain\`, or \`rough\` only when the user requests that
131
139
  register system. \`auto\` chooses address forms for the current relationship and scene,
132
140
  keeping them consistent within each reply; \`polite\` and \`plain\` fix those forms.
141
+ \`plain\` means ordinary non-honorific forms; \`rough\` requests rough, cheeky non-honorific
142
+ sentence endings. Preserve the configured address terms and every independent axis.
133
143
  It has no effect where the language lacks an honorific distinction. A request for more or
134
144
  less formality changes wording and distance within the chosen forms, without silently changing
135
145
  \`speech_level\`. Familiar polite, formal plain and affectionate swearing are valid combinations;
@@ -198,12 +208,13 @@ the user to complete CLI installation and explicit setup; do not install it or g
198
208
  ## Read the current state first
199
209
 
200
210
  \`\`\`bash
201
- tonedial show --json
211
+ tonedial get --json
202
212
  \`\`\`
203
213
 
204
- The payload carries \`axes\` (twelve axes, each 1–5), \`defaults\`, \`groups\`, \`kinds\`,
205
- \`riskAxes\`, \`persona\`, \`safeword\`, \`speech_level\`, and \`chars\`. Every command takes \`--json\` and answers with a
206
- \`schema\` field.
214
+ The \`values\` object carries the twelve axis levels and the separate settings under their
215
+ editable keys. An unset setting is \`null\`; description text is returned in full.
216
+ For the formatted overview, preset state and advisory \`riskAxes\`, use \`tonedial show --json\`.
217
+ Every command takes \`--json\` and answers with a \`schema\` field.
207
218
  ${JSON_MD}
208
219
  Otherwise report the failed step
209
220
  and stop that flow. Never report a failed mutation as saved; failures after a successful mutation
@@ -230,7 +241,7 @@ Report the movement back to the user in their own terms, from the \`moves\` fiel
230
241
  - "stop flattering me" → \`flattery=1\`. Not \`warmth\`, which is a different observable.
231
242
  - "be brief" → \`verbosity\` down. Change \`care\` only for requested coverage or organization changes.
232
243
  - "more casual" → \`formality\` down. Change the register system only on an explicit request,
233
- such as "use plain forms" → \`speech_level=plain\`.
244
+ such as "use plain forms" → \`speech_level=plain\`, or "use rough, cheeky plain forms" → \`speech_level=rough\`.
234
245
 
235
246
  For a named preset, run \`tonedial list --json\` then \`tonedial use <slug> --json\`.
236
247
  An explicit agent character or role request uses \`persona.agent\`; an explicit request to
@@ -308,7 +319,7 @@ During a test conversation, do not mutate ToneDial settings or perform work thro
308
319
  */
309
320
  export const SKILL_TRAITS_MD = `# Axes
310
321
 
311
- Twelve axes, each 1 to 5. Read the live values with \`tonedial show --json\`; this file says what
322
+ Twelve axes, each 1 to 5. Read the live values with \`tonedial get --json\`; this file says what
312
323
  each one **owns**, which is what tells you which axis a request is actually about.
313
324
 
314
325
  Axes name distinct aspects of a reply. Map the aspect the user named rather than changing
@@ -380,9 +391,11 @@ do not prove actual host activation or model compliance.
380
391
 
381
392
  ## Settings that are not axes
382
393
 
383
- \`speech_level\` picks address forms — \`auto\`, \`polite\`, or \`plain\` — where the language
394
+ \`speech_level\` picks address forms — \`auto\`, \`polite\`, \`plain\`, or \`rough\` — where the language
384
395
  has an honorific distinction, and does nothing where it does not. Auto adapts to the current
385
- relationship and scene, staying consistent within a reply. \`formality\` sets distance inside
396
+ relationship and scene, staying consistent within a reply. Plain uses ordinary non-honorific forms;
397
+ rough uses rough, cheeky non-honorific endings while keeping configured address terms.
398
+ \`formality\` sets distance inside
386
399
  the chosen forms; warmth and profanity keep their selected effects. The persona layer (\`age\`, \`gender\`, \`self_ref\`, \`user_ref\`,
387
400
  \`user\`, \`agent\`, \`note\`) and the safeword are included in bundled and saved custom profiles.
388
401
  \`persona.user\` is user context and \`persona.agent\` a fictional agent role, each limited to
@@ -402,12 +415,12 @@ Profanity grades actual swearing; bravado grades effort claims.
402
415
  * command name stays `/tone` even though the tool is `tonedial`: it is what a person types
403
416
  * mid-sentence, and the two extra characters are paid every time.
404
417
  */
405
- export const TONE_COMMAND_VERSION = 9;
418
+ export const TONE_COMMAND_VERSION = 11;
406
419
  export const TONE_COMMAND_MD = `---
407
420
  description: Check, change, or reload this agent's tone, preset, or ToneDial settings
408
421
  ---
409
422
 
410
- Run \`tonedial show --json\` and report the requested current settings in plain language.
423
+ Run \`tonedial get --json\` and report the requested current settings from \`values\` in plain language.
411
424
  Use the same installed CLI and environment throughout.
412
425
  ${JSON_MD}
413
426
  Otherwise report the failed step and stop that flow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tonedial",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Set a coding agent's manner once and keep it across every project.",
5
5
  "license": "MIT",
6
6
  "type": "module",