Skip to content

Commit a2d81e1

Browse files
committed
fix(context): strip .md suffix using correct byte length
The .md extension is three bytes, but the normalizer took the last four characters as the suffix, so USER.md compared R.md to .md and never stripped. Use MD_SUFFIX.len() for slicing and document optional .md on show/pull/push in help and the hotdata skill.
1 parent dd97968 commit a2d81e1

3 files changed

Lines changed: 56 additions & 12 deletions

File tree

skills/hotdata/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ If **`HOTDATA_WORKSPACE`** is set in the environment, the workspace is **locked*
4343

4444
The workspace stores those documents only through the **context API**. The **authoritative** copy always lives on the server under the stem; common stems are **`context:DATAMODEL`** (semantic map) and **`context:GLOSSARY`** (glossary / runbooks).
4545

46-
The CLI command **`hotdata context push`** reads **`./<NAME>.md`** and **`pull`** writes that file in the **current working directory**—those files exist only as a **transport surface** for the API, not as a second source of truth. **`hotdata context show <name>`** prints Markdown to stdout so agents can read **`context:<NAME>`** without any local file. Stems follow SQL table–identifier rules (ASCII letters, digits, underscore; no dot in the API name; max 128 characters; SQL reserved words are not allowed).
46+
The CLI command **`hotdata context push`** reads **`./<NAME>.md`** and **`pull`** writes that file in the **current working directory**—those files exist only as a **transport surface** for the API, not as a second source of truth. **`hotdata context show <name>`** prints Markdown to stdout so agents can read **`context:<NAME>`** without any local file. Stems follow SQL table–identifier rules (ASCII letters, digits, underscore; no dot in the API name; max 128 characters; SQL reserved words are not allowed). For **`show`**, **`pull`**, and **`push`**, the CLI accepts a trailing **`.md`** on the argument (e.g. **`USER.md`**) and treats it as stem **`USER`**—the workspace still stores **`USER`**, not `USER.md`.
4747

4848
> **Agents: do not blindly run `hotdata context show DATAMODEL` on session start.** Run **`hotdata context list`** first (optional `--prefix DATAMODEL`). Call **`hotdata context show DATAMODEL` only if** the list includes the `DATAMODEL` stem. If **`show` exits 1** with *no context named …*, that is **normal** when nothing has been pushed yet—**not a hard failure**; do not retry in a loop, and **avoid speculative `show` in parallel** with other shell tools where one failure cancels sibling calls. Proceed without **context:DATAMODEL** until the user asks to create or load one.
4949

src/command.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -582,13 +582,13 @@ pub enum ContextCommands {
582582

583583
/// Print context content to stdout
584584
Show {
585-
/// Context name (same rules as a SQL table identifier; local file is <NAME>.md)
585+
/// Context name (same rules as a SQL table identifier; local file is <NAME>.md). A trailing `.md` is ignored (e.g. `USER.md` → `USER`).
586586
name: String,
587587
},
588588

589589
/// Download context from the workspace to ./<NAME>.md
590590
Pull {
591-
/// Context name
591+
/// Context name (trailing `.md` ignored, e.g. `USER.md` → `USER`)
592592
name: String,
593593

594594
/// Overwrite ./<NAME>.md if it already exists
@@ -602,7 +602,7 @@ pub enum ContextCommands {
602602

603603
/// Upload ./<NAME>.md to the workspace as named context
604604
Push {
605-
/// Context name
605+
/// Context name (trailing `.md` ignored, e.g. `USER.md` → `USER`; reads `./USER.md`)
606606
name: String,
607607

608608
/// Print what would be sent; do not POST

src/context.rs

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,25 @@ struct UpsertResponse {
4949
context: WorkspaceContextEntry,
5050
}
5151

52+
/// Normalizes a context name from the CLI: trims, takes the final path segment, and strips a
53+
/// trailing `.md` (any ASCII case) so `USER.md` or `./USER.md` refer to context stem `USER`.
54+
pub fn normalize_context_cli_name(name: &str) -> String {
55+
let trimmed = name.trim();
56+
let basename = std::path::Path::new(trimmed)
57+
.file_name()
58+
.and_then(|n| n.to_str())
59+
.unwrap_or(trimmed);
60+
const MD_SUFFIX: &str = ".md";
61+
if basename.len() >= MD_SUFFIX.len() {
62+
let start = basename.len() - MD_SUFFIX.len();
63+
let suffix = &basename[start..];
64+
if suffix.eq_ignore_ascii_case(MD_SUFFIX) {
65+
return basename[..start].to_string();
66+
}
67+
}
68+
basename.to_string()
69+
}
70+
5271
/// Validates a context stem (API `name` and basename before `.md`).
5372
/// Same rules as runtimedb `validate_table_name`.
5473
pub fn validate_context_stem(name: &str) -> Result<(), String> {
@@ -148,13 +167,14 @@ pub fn list(workspace_id: &str, prefix: Option<&str>, format: &str) {
148167
}
149168

150169
pub fn show(workspace_id: &str, name: &str) {
151-
if let Err(e) = validate_context_stem(name) {
170+
let name = normalize_context_cli_name(name);
171+
if let Err(e) = validate_context_stem(&name) {
152172
eprintln!("error: {e}");
153173
std::process::exit(1);
154174
}
155175

156176
let api = ApiClient::new(Some(workspace_id));
157-
match fetch_context(&api, name) {
177+
match fetch_context(&api, &name) {
158178
Ok(ctx) => {
159179
print!("{}", ctx.content);
160180
if !ctx.content.ends_with('\n') {
@@ -178,12 +198,13 @@ pub fn show(workspace_id: &str, name: &str) {
178198
}
179199

180200
pub fn pull(workspace_id: &str, name: &str, force: bool, dry_run: bool) {
181-
if let Err(e) = validate_context_stem(name) {
201+
let name = normalize_context_cli_name(name);
202+
if let Err(e) = validate_context_stem(&name) {
182203
eprintln!("error: {e}");
183204
std::process::exit(1);
184205
}
185206

186-
let path = local_md_path(name);
207+
let path = local_md_path(&name);
187208

188209
if !dry_run && !force && path.exists() {
189210
eprintln!(
@@ -194,7 +215,7 @@ pub fn pull(workspace_id: &str, name: &str, force: bool, dry_run: bool) {
194215
}
195216

196217
let api = ApiClient::new(Some(workspace_id));
197-
let ctx = match fetch_context(&api, name) {
218+
let ctx = match fetch_context(&api, &name) {
198219
Ok(c) => c,
199220
Err(reqwest::StatusCode::NOT_FOUND) => {
200221
eprintln!(
@@ -232,12 +253,13 @@ pub fn pull(workspace_id: &str, name: &str, force: bool, dry_run: bool) {
232253
}
233254

234255
pub fn push(workspace_id: &str, name: &str, dry_run: bool) {
235-
if let Err(e) = validate_context_stem(name) {
256+
let name = normalize_context_cli_name(name);
257+
if let Err(e) = validate_context_stem(&name) {
236258
eprintln!("error: {e}");
237259
std::process::exit(1);
238260
}
239261

240-
let path = local_md_path(name);
262+
let path = local_md_path(&name);
241263
if !path.is_file() {
242264
eprintln!(
243265
"{}",
@@ -269,7 +291,7 @@ pub fn push(workspace_id: &str, name: &str, dry_run: bool) {
269291
}
270292

271293
let api = ApiClient::new(Some(workspace_id));
272-
let body = json!({ "name": name, "content": content });
294+
let body = json!({ "name": &name, "content": content });
273295
let resp: UpsertResponse = api.post("/context", &body);
274296

275297
println!(
@@ -330,4 +352,26 @@ mod tests {
330352
fn validate_rejects_reserved_uppercase() {
331353
assert!(validate_context_stem("SELECT").is_err());
332354
}
355+
356+
#[test]
357+
fn normalize_strips_trailing_md() {
358+
assert_eq!(normalize_context_cli_name("USER.md"), "USER");
359+
assert_eq!(normalize_context_cli_name("USER.MD"), "USER");
360+
assert_eq!(normalize_context_cli_name(" USER.md "), "USER");
361+
}
362+
363+
#[test]
364+
fn normalize_accepts_path_with_md() {
365+
assert_eq!(normalize_context_cli_name("./DATAMODEL.md"), "DATAMODEL");
366+
}
367+
368+
#[test]
369+
fn normalize_preserves_stem_without_md() {
370+
assert_eq!(normalize_context_cli_name("DATAMODEL"), "DATAMODEL");
371+
}
372+
373+
#[test]
374+
fn normalize_strips_md_one_char_stem() {
375+
assert_eq!(normalize_context_cli_name("a.md"), "a");
376+
}
333377
}

0 commit comments

Comments
 (0)