Skip to content

Commit 73209ea

Browse files
authored
Merge pull request #59 from hotdata-dev/feat/context-push-strip-md-extension
fix(context): accept USER.md-style names for show/pull/push
2 parents dd97968 + ce70686 commit 73209ea

3 files changed

Lines changed: 73 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: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,31 @@ 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+
let md_len = MD_SUFFIX.len();
62+
let bytes = basename.as_bytes();
63+
if bytes.len() >= md_len {
64+
let i = bytes.len() - md_len;
65+
// Inspect bytes only: avoid slicing `str` at `i` until we know the last `md_len` bytes are
66+
// ASCII `.md` (so `i` is a UTF-8 char boundary — e.g. `x𝕌` must not index `basename[2..]`).
67+
if bytes[i] == b'.'
68+
&& bytes[i + 1].eq_ignore_ascii_case(&b'm')
69+
&& bytes[i + 2].eq_ignore_ascii_case(&b'd')
70+
{
71+
return basename[..i].to_string();
72+
}
73+
}
74+
basename.to_string()
75+
}
76+
5277
/// Validates a context stem (API `name` and basename before `.md`).
5378
/// Same rules as runtimedb `validate_table_name`.
5479
pub fn validate_context_stem(name: &str) -> Result<(), String> {
@@ -148,13 +173,14 @@ pub fn list(workspace_id: &str, prefix: Option<&str>, format: &str) {
148173
}
149174

150175
pub fn show(workspace_id: &str, name: &str) {
151-
if let Err(e) = validate_context_stem(name) {
176+
let name = normalize_context_cli_name(name);
177+
if let Err(e) = validate_context_stem(&name) {
152178
eprintln!("error: {e}");
153179
std::process::exit(1);
154180
}
155181

156182
let api = ApiClient::new(Some(workspace_id));
157-
match fetch_context(&api, name) {
183+
match fetch_context(&api, &name) {
158184
Ok(ctx) => {
159185
print!("{}", ctx.content);
160186
if !ctx.content.ends_with('\n') {
@@ -178,12 +204,13 @@ pub fn show(workspace_id: &str, name: &str) {
178204
}
179205

180206
pub fn pull(workspace_id: &str, name: &str, force: bool, dry_run: bool) {
181-
if let Err(e) = validate_context_stem(name) {
207+
let name = normalize_context_cli_name(name);
208+
if let Err(e) = validate_context_stem(&name) {
182209
eprintln!("error: {e}");
183210
std::process::exit(1);
184211
}
185212

186-
let path = local_md_path(name);
213+
let path = local_md_path(&name);
187214

188215
if !dry_run && !force && path.exists() {
189216
eprintln!(
@@ -194,7 +221,7 @@ pub fn pull(workspace_id: &str, name: &str, force: bool, dry_run: bool) {
194221
}
195222

196223
let api = ApiClient::new(Some(workspace_id));
197-
let ctx = match fetch_context(&api, name) {
224+
let ctx = match fetch_context(&api, &name) {
198225
Ok(c) => c,
199226
Err(reqwest::StatusCode::NOT_FOUND) => {
200227
eprintln!(
@@ -232,12 +259,13 @@ pub fn pull(workspace_id: &str, name: &str, force: bool, dry_run: bool) {
232259
}
233260

234261
pub fn push(workspace_id: &str, name: &str, dry_run: bool) {
235-
if let Err(e) = validate_context_stem(name) {
262+
let name = normalize_context_cli_name(name);
263+
if let Err(e) = validate_context_stem(&name) {
236264
eprintln!("error: {e}");
237265
std::process::exit(1);
238266
}
239267

240-
let path = local_md_path(name);
268+
let path = local_md_path(&name);
241269
if !path.is_file() {
242270
eprintln!(
243271
"{}",
@@ -269,7 +297,7 @@ pub fn push(workspace_id: &str, name: &str, dry_run: bool) {
269297
}
270298

271299
let api = ApiClient::new(Some(workspace_id));
272-
let body = json!({ "name": name, "content": content });
300+
let body = json!({ "name": &name, "content": content });
273301
let resp: UpsertResponse = api.post("/context", &body);
274302

275303
println!(
@@ -330,4 +358,37 @@ mod tests {
330358
fn validate_rejects_reserved_uppercase() {
331359
assert!(validate_context_stem("SELECT").is_err());
332360
}
361+
362+
#[test]
363+
fn normalize_strips_trailing_md() {
364+
assert_eq!(normalize_context_cli_name("USER.md"), "USER");
365+
assert_eq!(normalize_context_cli_name("USER.MD"), "USER");
366+
assert_eq!(normalize_context_cli_name(" USER.md "), "USER");
367+
}
368+
369+
#[test]
370+
fn normalize_accepts_path_with_md() {
371+
assert_eq!(normalize_context_cli_name("./DATAMODEL.md"), "DATAMODEL");
372+
}
373+
374+
#[test]
375+
fn normalize_preserves_stem_without_md() {
376+
assert_eq!(normalize_context_cli_name("DATAMODEL"), "DATAMODEL");
377+
}
378+
379+
#[test]
380+
fn normalize_strips_md_one_char_stem() {
381+
assert_eq!(normalize_context_cli_name("a.md"), "a");
382+
}
383+
384+
#[test]
385+
fn normalize_does_not_panic_multibyte_stem_without_md() {
386+
// 1 ASCII byte + 4-byte UTF-8; byte index 2 is inside the codepoint — must not slice there.
387+
assert_eq!(normalize_context_cli_name("x𝕌"), "x𝕌");
388+
}
389+
390+
#[test]
391+
fn normalize_strips_md_after_multibyte_char() {
392+
assert_eq!(normalize_context_cli_name("x𝕌.md"), "x𝕌");
393+
}
333394
}

0 commit comments

Comments
 (0)