@oh-my-pi/pi-coding-agent 12.12.2 → 12.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.13.0] - 2026-02-19
6
+ ### Breaking Changes
7
+
8
+ - Removed automatic line relocation when hash references become stale; edits with mismatched line hashes now fail with an error instead of silently relocating to matching lines elsewhere in the file
9
+
10
+ ### Added
11
+
12
+ - Added `ssh` command for managing SSH host configurations (add, list, remove)
13
+ - Added `/ssh` slash command in interactive mode to manage SSH hosts with subcommands
14
+ - Added support for SSH host configuration at project and user scopes (.omp/ssh.json and ~/.omp/agent/ssh.json)
15
+ - Added `--host`, `--user`, `--port`, `--key`, `--desc`, `--compat`, and `--scope` flags for SSH host configuration
16
+ - Added discovery of SSH hosts from project configuration files alongside manually configured hosts
17
+ - Added NanoGPT as a login provider (`/login nanogpt`) with API key prompt flow linking to `https://nano-gpt.com/api` ([#111](https://github.com/can1357/oh-my-pi/issues/111))
18
+
19
+ ### Changed
20
+
21
+ - Updated hashline reference format from `LINE:HASH` to `LINE#ID` throughout the codebase for improved clarity
22
+ - Renamed hashline edit operations: `set_line` → `set`, `replace_lines` → `set_range`, `insert_after` → `insert` with support for `before` and `between` anchors
23
+ - Changed hashline edit `body` field from string to array of strings for clearer multiline handling
24
+ - Updated handlebars helpers: renamed `hashline` to `hlineref` and added `hlinefull` for formatted line output
25
+ - Improved insert operation to support `before`, `after`, and `between` (both anchors) positioning modes
26
+ - Made autocorrect heuristics (boundary echo stripping, indent restoration) conditional on `PI_HL_AUTOCORRECT` environment variable
27
+ - Updated SSH host discovery to load from managed omp config paths (.omp/ssh.json and ~/.omp/agent/ssh.json) in addition to legacy root-level ssh.json and .ssh.json files
28
+ - Improved terminal output handling in interactive bash sessions to ensure all queued writes complete before returning results
29
+
30
+ ### Fixed
31
+
32
+ - Fixed insert-between operation to properly validate adjacent anchor lines and strip boundary echoes from both sides
33
+ - Fixed terminal output handling to properly queue and serialize writes, preventing dropped or corrupted output in interactive bash sessions
34
+
5
35
  ## [12.12.1] - 2026-02-19
6
36
 
7
37
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "12.12.2",
3
+ "version": "12.13.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,12 +84,12 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@mozilla/readability": "0.6.0",
87
- "@oh-my-pi/omp-stats": "12.12.2",
88
- "@oh-my-pi/pi-agent-core": "12.12.2",
89
- "@oh-my-pi/pi-ai": "12.12.2",
90
- "@oh-my-pi/pi-natives": "12.12.2",
91
- "@oh-my-pi/pi-tui": "12.12.2",
92
- "@oh-my-pi/pi-utils": "12.12.2",
87
+ "@oh-my-pi/omp-stats": "12.13.0",
88
+ "@oh-my-pi/pi-agent-core": "12.13.0",
89
+ "@oh-my-pi/pi-ai": "12.13.0",
90
+ "@oh-my-pi/pi-natives": "12.13.0",
91
+ "@oh-my-pi/pi-tui": "12.13.0",
92
+ "@oh-my-pi/pi-utils": "12.13.0",
93
93
  "@sinclair/typebox": "^0.34.48",
94
94
  "@xterm/headless": "^6.0.0",
95
95
  "ajv": "^8.18.0",
@@ -0,0 +1,179 @@
1
+ /**
2
+ * SSH CLI command handlers.
3
+ *
4
+ * Handles `omp ssh <command>` subcommands for SSH host configuration management.
5
+ */
6
+
7
+ import { getSSHConfigPath } from "@oh-my-pi/pi-utils/dirs";
8
+ import chalk from "chalk";
9
+ import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../ssh/config-writer";
10
+
11
+ // =============================================================================
12
+ // Types
13
+ // =============================================================================
14
+
15
+ export type SSHAction = "add" | "remove" | "list";
16
+
17
+ export interface SSHCommandArgs {
18
+ action: SSHAction;
19
+ args: string[];
20
+ flags: {
21
+ json?: boolean;
22
+ host?: string;
23
+ user?: string;
24
+ port?: string;
25
+ key?: string;
26
+ desc?: string;
27
+ compat?: boolean;
28
+ scope?: "project" | "user";
29
+ };
30
+ }
31
+
32
+ // =============================================================================
33
+ // Main dispatcher
34
+ // =============================================================================
35
+
36
+ export async function runSSHCommand(cmd: SSHCommandArgs): Promise<void> {
37
+ switch (cmd.action) {
38
+ case "add":
39
+ await handleAdd(cmd);
40
+ break;
41
+ case "remove":
42
+ await handleRemove(cmd);
43
+ break;
44
+ case "list":
45
+ await handleList(cmd);
46
+ break;
47
+ default:
48
+ process.stdout.write(chalk.red(`Unknown action: ${cmd.action}\n`));
49
+ process.stdout.write(`Valid actions: add, remove, list\n`);
50
+ process.exitCode = 1;
51
+ }
52
+ }
53
+
54
+ // =============================================================================
55
+ // Handlers
56
+ // =============================================================================
57
+
58
+ async function handleAdd(cmd: SSHCommandArgs): Promise<void> {
59
+ const name = cmd.args[0];
60
+ if (!name) {
61
+ process.stdout.write(chalk.red("Error: Host name required\n"));
62
+ process.stdout.write(
63
+ chalk.dim("Usage: omp ssh add <name> --host <address> [--user <user>] [--port <port>] [--key <path>]\n"),
64
+ );
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+
69
+ const host = cmd.flags.host;
70
+ if (!host) {
71
+ process.stdout.write(chalk.red("Error: --host is required\n"));
72
+ process.stdout.write(chalk.dim("Usage: omp ssh add <name> --host <address>\n"));
73
+ process.exitCode = 1;
74
+ return;
75
+ }
76
+
77
+ // Validate port if provided
78
+ if (cmd.flags.port !== undefined) {
79
+ const port = Number.parseInt(cmd.flags.port, 10);
80
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
81
+ process.stdout.write(chalk.red("Error: Port must be an integer between 1 and 65535\n"));
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ }
86
+
87
+ const hostConfig: SSHHostConfig = { host };
88
+ if (cmd.flags.user) hostConfig.username = cmd.flags.user;
89
+ if (cmd.flags.port) hostConfig.port = Number.parseInt(cmd.flags.port, 10);
90
+ if (cmd.flags.key) hostConfig.keyPath = cmd.flags.key;
91
+ if (cmd.flags.desc) hostConfig.description = cmd.flags.desc;
92
+ if (cmd.flags.compat) hostConfig.compat = true;
93
+
94
+ const scope = cmd.flags.scope ?? "project";
95
+ const filePath = getSSHConfigPath(scope);
96
+
97
+ try {
98
+ await addSSHHost(filePath, name, hostConfig);
99
+ process.stdout.write(chalk.green(`Added SSH host "${name}" to ${scope} config\n`));
100
+ } catch (err) {
101
+ process.stdout.write(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}\n`));
102
+ process.exitCode = 1;
103
+ }
104
+ }
105
+
106
+ async function handleRemove(cmd: SSHCommandArgs): Promise<void> {
107
+ const name = cmd.args[0];
108
+ if (!name) {
109
+ process.stdout.write(chalk.red("Error: Host name required\n"));
110
+ process.stdout.write(chalk.dim("Usage: omp ssh remove <name> [--scope project|user]\n"));
111
+ process.exitCode = 1;
112
+ return;
113
+ }
114
+
115
+ const scope = cmd.flags.scope ?? "project";
116
+ const filePath = getSSHConfigPath(scope);
117
+
118
+ try {
119
+ await removeSSHHost(filePath, name);
120
+ process.stdout.write(chalk.green(`Removed SSH host "${name}" from ${scope} config\n`));
121
+ } catch (err) {
122
+ process.stdout.write(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}\n`));
123
+ process.exitCode = 1;
124
+ }
125
+ }
126
+
127
+ async function handleList(cmd: SSHCommandArgs): Promise<void> {
128
+ const projectPath = getSSHConfigPath("project");
129
+ const userPath = getSSHConfigPath("user");
130
+
131
+ const [projectConfig, userConfig] = await Promise.all([readSSHConfigFile(projectPath), readSSHConfigFile(userPath)]);
132
+
133
+ const projectHosts = projectConfig.hosts ?? {};
134
+ const userHosts = userConfig.hosts ?? {};
135
+
136
+ if (cmd.flags.json) {
137
+ process.stdout.write(JSON.stringify({ project: projectHosts, user: userHosts }, null, 2));
138
+ process.stdout.write("\n");
139
+ return;
140
+ }
141
+
142
+ const hasProject = Object.keys(projectHosts).length > 0;
143
+ const hasUser = Object.keys(userHosts).length > 0;
144
+
145
+ if (!hasProject && !hasUser) {
146
+ process.stdout.write(chalk.dim("No SSH hosts configured\n"));
147
+ process.stdout.write(chalk.dim("Add one with: omp ssh add <name> --host <address>\n"));
148
+ return;
149
+ }
150
+
151
+ if (hasProject) {
152
+ process.stdout.write(chalk.bold("Project SSH Hosts (.omp/ssh.json):\n"));
153
+ printHosts(projectHosts);
154
+ }
155
+
156
+ if (hasProject && hasUser) {
157
+ process.stdout.write("\n");
158
+ }
159
+
160
+ if (hasUser) {
161
+ process.stdout.write(chalk.bold("User SSH Hosts (~/.omp/agent/ssh.json):\n"));
162
+ printHosts(userHosts);
163
+ }
164
+ }
165
+
166
+ // =============================================================================
167
+ // Helpers
168
+ // =============================================================================
169
+
170
+ function printHosts(hosts: Record<string, SSHHostConfig>): void {
171
+ for (const [name, config] of Object.entries(hosts)) {
172
+ const parts = [chalk.cyan(name), config.host];
173
+ if (config.username) parts.push(chalk.dim(config.username));
174
+ if (config.port && config.port !== 22) parts.push(chalk.dim(`port:${config.port}`));
175
+ if (config.keyPath) parts.push(chalk.dim(config.keyPath));
176
+ if (config.description) parts.push(chalk.dim(`- ${config.description}`));
177
+ process.stdout.write(` ${parts.join(" ")}\n`);
178
+ }
179
+ }
package/src/cli.ts CHANGED
@@ -23,6 +23,7 @@ const commands: CommandEntry[] = [
23
23
  { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
24
24
  { name: "setup", load: () => import("./commands/setup").then(m => m.default) },
25
25
  { name: "shell", load: () => import("./commands/shell").then(m => m.default) },
26
+ { name: "ssh", load: () => import("./commands/ssh").then(m => m.default) },
26
27
  { name: "stats", load: () => import("./commands/stats").then(m => m.default) },
27
28
  { name: "update", load: () => import("./commands/update").then(m => m.default) },
28
29
  { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Manage SSH host configurations.
3
+ */
4
+ import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
5
+ import { runSSHCommand, type SSHAction, type SSHCommandArgs } from "../cli/ssh-cli";
6
+ import { initTheme } from "../modes/theme/theme";
7
+
8
+ const ACTIONS: SSHAction[] = ["add", "remove", "list"];
9
+
10
+ export default class SSH extends Command {
11
+ static description = "Manage SSH host configurations";
12
+
13
+ static args = {
14
+ action: Args.string({
15
+ description: "SSH action",
16
+ required: false,
17
+ options: ACTIONS,
18
+ }),
19
+ targets: Args.string({
20
+ description: "Host name or arguments",
21
+ required: false,
22
+ multiple: true,
23
+ }),
24
+ };
25
+
26
+ static flags = {
27
+ json: Flags.boolean({ description: "Output JSON" }),
28
+ host: Flags.string({ description: "Host address" }),
29
+ user: Flags.string({ description: "Username" }),
30
+ port: Flags.string({ description: "Port number" }),
31
+ key: Flags.string({ description: "Identity key path" }),
32
+ desc: Flags.string({ description: "Host description" }),
33
+ compat: Flags.boolean({ description: "Enable compatibility mode" }),
34
+ scope: Flags.string({ description: "Config scope (project|user)", options: ["project", "user"] }),
35
+ };
36
+
37
+ async run(): Promise<void> {
38
+ const { args, flags } = await this.parse(SSH);
39
+ const action = (args.action ?? "list") as SSHAction;
40
+ const targets = Array.isArray(args.targets) ? args.targets : args.targets ? [args.targets] : [];
41
+
42
+ const cmd: SSHCommandArgs = {
43
+ action,
44
+ args: targets,
45
+ flags: {
46
+ json: flags.json,
47
+ host: flags.host,
48
+ user: flags.user,
49
+ port: flags.port,
50
+ key: flags.key,
51
+ desc: flags.desc,
52
+ compat: flags.compat,
53
+ scope: flags.scope as "project" | "user" | undefined,
54
+ },
55
+ };
56
+
57
+ await initTheme();
58
+ await runSSHCommand(cmd);
59
+ }
60
+ }
@@ -76,7 +76,6 @@ Call create_conventional_analysis with:
76
76
  }
77
77
  </output_format>
78
78
 
79
- <examples>
80
79
  <example name="feature-with-api">
81
80
  {
82
81
  "type": "feat",
@@ -146,5 +145,4 @@ Call create_conventional_analysis with:
146
145
  "details": [],
147
146
  "issue_refs": []
148
147
  }
149
- </example>
150
- </examples>
148
+ </example>
@@ -230,13 +230,28 @@ handlebars.registerHelper("jtdToTypeScript", (schema: unknown): string => jtdToT
230
230
  handlebars.registerHelper("jsonStringify", (value: unknown): string => JSON.stringify(value));
231
231
 
232
232
  /**
233
- * {{hashline lineNum "content"}} — compute a real hashline ref for prompt examples.
234
- * Returns `"lineNum:hash"` using the actual hash algorithm.
233
+ * {{hlineref lineNum "content"}} — compute a real hashline ref for prompt examples.
234
+ * Returns `"lineNum#hash"` using the actual hash algorithm.
235
235
  */
236
- handlebars.registerHelper("hashline", (lineNum: unknown, content: unknown): string => {
236
+ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } {
237
237
  const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10);
238
- const str = typeof content === "string" ? content : String(content ?? "");
239
- return `${num}:${computeLineHash(num, str)}`;
238
+ const text = typeof content === "string" ? content : String(content ?? "");
239
+ const ref = `${num}#${computeLineHash(num, text)}`;
240
+ return { num, text, ref };
241
+ }
242
+
243
+ handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): string => {
244
+ const { ref } = formatHashlineRef(lineNum, content);
245
+ return ref;
246
+ });
247
+
248
+ /**
249
+ * {{hlinefull lineNum "content"}} — format a full read-style line with prefix.
250
+ * Returns `"lineNum#hash|content"`.
251
+ */
252
+ handlebars.registerHelper("hlinefull", (lineNum: unknown, content: unknown): string => {
253
+ const { ref, text } = formatHashlineRef(lineNum, content);
254
+ return `${ref}|${text}`;
240
255
  });
241
256
 
242
257
  export function renderPromptTemplate(template: string, context: TemplateContext = {}): string {
@@ -287,7 +287,7 @@ export const SETTINGS_SCHEMA = {
287
287
  ui: {
288
288
  tab: "config",
289
289
  label: "Read hash lines",
290
- description: "Include line hashes in read output for hashline edit mode (LINE:HASH|content)",
290
+ description: "Include line hashes in read output for hashline edit mode (LINE#ID|content)",
291
291
  },
292
292
  },
293
293
  showHardwareCursor: {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * SSH JSON Provider
3
3
  *
4
- * Discovers SSH hosts from ssh.json or .ssh.json in the project root.
5
- * Priority: 5 (low, project-level only)
4
+ * Discovers SSH hosts from managed omp config paths and legacy root ssh.json files.
5
+ * Priority: 5 (low, project/user config discovery)
6
6
  */
7
7
  import * as path from "node:path";
8
+ import { getSSHConfigPath } from "@oh-my-pi/pi-utils/dirs";
8
9
  import { registerProvider } from "../capability";
9
10
  import { readFile } from "../capability/fs";
10
11
  import { type SSHHost, sshCapability } from "../capability/ssh";
@@ -90,38 +91,39 @@ function normalizeHost(
90
91
  };
91
92
  }
92
93
 
93
- async function loadSshJsonFile(_ctx: LoadContext, path: string): Promise<LoadResult<SSHHost>> {
94
+ async function loadSshJsonFile(
95
+ ctx: LoadContext,
96
+ filePath: string,
97
+ level: "user" | "project",
98
+ ): Promise<LoadResult<SSHHost>> {
94
99
  const items: SSHHost[] = [];
95
100
  const warnings: string[] = [];
96
-
97
- const content = await readFile(path);
101
+ const content = await readFile(filePath);
98
102
  if (content === null) {
99
103
  return { items, warnings };
100
104
  }
101
-
102
105
  const parsed = parseJSON<SSHConfigFile>(content);
103
106
  if (!parsed) {
104
- warnings.push(`Failed to parse JSON in ${path}`);
107
+ warnings.push(`Failed to parse JSON in ${filePath}`);
105
108
  return { items, warnings };
106
109
  }
107
-
108
110
  const config = expandEnvVarsDeep(parsed);
109
111
  if (!config.hosts || typeof config.hosts !== "object") {
110
- warnings.push(`Missing hosts in ${path}`);
112
+ warnings.push(`Missing hosts in ${filePath}`);
111
113
  return { items, warnings };
112
114
  }
113
115
 
114
- const source = createSourceMeta(PROVIDER_ID, path, "project");
116
+ const source = createSourceMeta(PROVIDER_ID, filePath, level);
115
117
  for (const [name, rawHost] of Object.entries(config.hosts)) {
116
118
  if (!name.trim()) {
117
- warnings.push(`Invalid SSH host name in ${path}`);
119
+ warnings.push(`Invalid SSH host name in ${filePath}`);
118
120
  continue;
119
121
  }
120
122
  if (!rawHost || typeof rawHost !== "object") {
121
- warnings.push(`Invalid host entry in ${path}: ${name}`);
123
+ warnings.push(`Invalid host entry in ${filePath}: ${name}`);
122
124
  continue;
123
125
  }
124
- const host = normalizeHost(name, rawHost, source, _ctx.home, warnings);
126
+ const host = normalizeHost(name, rawHost, source, ctx.home, warnings);
125
127
  if (host) items.push(host);
126
128
  }
127
129
 
@@ -130,14 +132,19 @@ async function loadSshJsonFile(_ctx: LoadContext, path: string): Promise<LoadRes
130
132
  warnings: warnings.length > 0 ? warnings : undefined,
131
133
  };
132
134
  }
133
-
134
135
  async function load(ctx: LoadContext): Promise<LoadResult<SSHHost>> {
135
- const filenames = ["ssh.json", ".ssh.json"];
136
- const results = await Promise.all(filenames.map(filename => loadSshJsonFile(ctx, path.join(ctx.cwd, filename))));
137
-
136
+ const candidateSources: Array<{ path: string; level: "user" | "project" }> = [
137
+ { path: getSSHConfigPath("project", ctx.cwd), level: "project" },
138
+ { path: getSSHConfigPath("user", ctx.cwd), level: "user" },
139
+ { path: path.join(ctx.cwd, "ssh.json"), level: "project" },
140
+ { path: path.join(ctx.cwd, ".ssh.json"), level: "project" },
141
+ ];
142
+ const uniqueSources = candidateSources.filter(
143
+ (source, index, arr) => arr.findIndex(candidate => candidate.path === source.path) === index,
144
+ );
145
+ const results = await Promise.all(uniqueSources.map(source => loadSshJsonFile(ctx, source.path, source.level)));
138
146
  const allItems = results.flatMap(r => r.items);
139
147
  const allWarnings = results.flatMap(r => r.warnings ?? []);
140
-
141
148
  return {
142
149
  items: allItems,
143
150
  warnings: allWarnings.length > 0 ? allWarnings : undefined,
@@ -147,7 +154,7 @@ async function load(ctx: LoadContext): Promise<LoadResult<SSHHost>> {
147
154
  registerProvider(sshCapability.id, {
148
155
  id: PROVIDER_ID,
149
156
  displayName: DISPLAY_NAME,
150
- description: "Load SSH hosts from ssh.json or .ssh.json in the project root",
157
+ description: "Load SSH hosts from managed omp paths and legacy ssh.json/.ssh.json files",
151
158
  priority: 5,
152
159
  load,
153
160
  });