Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ dev-coach/
│ │ ├── claude-history.ts # cross-project stack scan of ~/.claude (projects map, manifests, activity, memories)
│ │ ├── courses.ts # courses: directory + document validation over the db.ts rows (see Courses below)
│ │ ├── runtime.ts # where devcoach runs from: classifyPath (channel × Node manager × OS), describeRuntime, the stderr startup line
│ │ ├── text.ts # stripControlChars(+Inline/Deep): escape sequences + control chars out of someone else's text
│ ├── mcp/server.ts # McpServer: 25 tools + 11 resources + devcoach_instructions prompt
│ ├── cli/commands.ts # Commander dispatcher (35 subcommands: 27 visible + 8 hidden hooks) + term.ts (colours, tables, OSC 8 link()) + open.ts (browser)
│ ├── cli/commands.ts # Commander dispatcher (35 subcommands: 27 visible + 8 hidden hooks) + term.ts (colours, tables, displayWidth, sanitizeForTerminal, trustedLink, rule) + open.ts (browser)
│ │ # install.ts (install/uninstall/doctor) + mcp-command.ts (resolveCommand, chooseServerCommand, checkServerCommand)
│ └── web/app.ts # Hono app (33 routes incl. POST /shutdown) + views.ts (hono/html pages); assets/static/share.js
│ # startUi returns the server; SIGINT/SIGTERM/SIGHUP → gracefulShutdown (close, 2 s drain, exit);
Expand Down Expand Up @@ -360,6 +361,8 @@ and the docs page `website/src/pages/lesson.tsx` — whose dashboard probe (`web
mixed content (bug 171934, open), so Safari/iOS get an "unsupported" state and NO fetch; Chrome 142+ gates
loopback behind the `local-network-access` permission (queried before/after the fetch to tell a refusal
from a dead dashboard; `targetAddressSpace: "loopback"` on the request). Hardening (from two ultrareview rounds, keep it):
imported text is stripped of escape sequences and control characters (`importSharedLesson`, `core/text.ts`) and
the CLI strips every printed lesson/course string again (`printable`, `sanitizeLine`) — only `trustedLink` emits OSC 8;
markdown from lessons is always `DOMPurify.sanitize`d before `innerHTML` (dashboard) / rendered
through `marked` + DOMPurify (docs page); `decodeShareCode` caps the input at `MAX_CODE_CHARS` and
inflates with a byte budget (`inflateBounded`; the docs decoder streams the same way); `fetchSharedInput`
Expand Down
5 changes: 4 additions & 1 deletion docs/usage/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ with `devcoach set share_name`).
|---|---|
| `DEVCOACH_DIR` | Relocate the data directory (default `~/.devcoach`) — sandboxing, tests, a second profile |
| `DEVCOACH_HOOK_DEBUG=1` | Trace every hook decision to `~/.devcoach/hook.log` |
| `NO_COLOR` | Disable coloured terminal output |
| `NO_COLOR` | Disable coloured terminal output (wins over `FORCE_COLOR`) |
| `FORCE_COLOR` | Colour even on a pipe; `0` or `false` turns colour off. `TERM=dumb` turns it off unless forced |
| `FORCE_HYPERLINK=1` | Make the dashboard URL a clickable OSC 8 link on terminals devcoach does not detect |
| `COLUMNS` | Width for rules when stdout is not a terminal (default 80, rules stop at 100) |

## Examples

Expand Down
11 changes: 7 additions & 4 deletions docs/usage/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,11 @@ See [Privacy & security](../reference/privacy.md#sharing-is-explicit) for the fu

```text
$ devcoach lessons --imported
╭───┬────────────┬────────┬─────────────────────────────────────────┬───────┬──────────────┬───────────╮
│ │ Date │ Topic │ Title │ Level │ Categories │ Shared by │
├───┼────────────┼────────┼─────────────────────────────────────────┼───────┼──────────────┼───────────┤
╭───┬────────────┬────────┬──────────────────────────────────────────┬───────┬──────────────┬───────────╮
│ │ Date │ Topic │ Title │ Level │ Categories │ Shared by │
├───┼────────────┼────────┼──────────────────────────────────────────┼───────┼──────────────┼───────────┤
│ · │ 2026-04-20 │ sqlite │ WAL mode: readers never block writers 💡 │ mid │ sqlite, node │ Ada │
╰───┴────────────┴────────┴─────────────────────────────────────────┴───────┴──────────────┴───────────╯
╰───┴────────────┴────────┴──────────────────────────────────────────┴───────┴──────────────┴───────────╯
```

- Its **topic counts as taught**, so the coach will not teach it again, and **✓/✗ feedback works**
Expand All @@ -189,6 +189,9 @@ $ devcoach lessons --imported
shared lesson is a gift, not a lesson delivered to you.
- Importing the same share twice reports *already in your log*; the same lesson from a different
sender is stored as a separate lesson.
- Its text is someone else's, so it is stored **inert**: terminal escape sequences and control
characters (a screen clear, a hidden link, a clipboard write, a right-to-left override) are removed
on import, and the CLI strips them again before it prints any lesson.

## The `.devcoach.md` format

Expand Down
47 changes: 30 additions & 17 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
sharedLessonFilename,
} from "../core/share";
import { fetchSharedInput, isHttpUrl } from "../core/share-fetch";
import { stripControlCharsDeep } from "../core/text";
import { VERSION } from "../version";
import { cmdDoctor, cmdInstall, cmdUninstall, skillHint } from "./install";
import { openInBrowser } from "./open";
Expand All @@ -31,6 +32,8 @@ import {
confidenceColor,
renderTable,
rule,
sanitizeForTerminal,
sanitizeLine,
} from "./term";

const log = (s = ""): void => {
Expand Down Expand Up @@ -62,6 +65,13 @@ const feedbackLabel = (fb: string | null): string =>
: c.dim("no feedback");
const FEEDBACK_VALUES = ["know", "understood", "dont_know", "clear"];

/**
* A lesson (or course) with every string made inert for the terminal. A shared lesson is someone
* else's text, and a row imported before the import-time strip may still carry escape sequences.
*/
const printable = <T extends object>(record: T): T => stripControlCharsDeep(record);
const senderLabel = (sharedBy: string | null): string => sharedBy ?? c.dim("anonymous");

// ── Display commands ─────────────────────────────────────────────────────────

/** Render the knowledge map as a Topic/Group/Confidence/Bar table (shared by `profile` + `setup`). */
Expand All @@ -73,8 +83,8 @@ function renderProfileTable(profile: ReturnType<typeof coach.getProfile>, title:
.map((e) => {
const color = confidenceColor(e.confidence);
return [
e.topic,
topicGroup.get(e.topic) ?? "Other",
sanitizeLine(e.topic),
sanitizeLine(topicGroup.get(e.topic) ?? "Other"),
colorize(color, `${e.confidence}/10`),
colorize(color, confidenceBar(e.confidence)),
];
Expand Down Expand Up @@ -166,7 +176,7 @@ function cmdLessons(o: LessonsOpts): void {
if (hasMeta) columns.push({ header: "Project" }, { header: "Branch" }, { header: "Commit" });
const hasShared = lessons.some((l) => l.imported);
if (hasShared) columns.push({ header: "Shared by" });
const rows = lessons.map((l) => {
const rows = lessons.map(printable).map((l) => {
const row = [
l.starred ? c.yellow("★") : c.dim("·"),
l.timestamp.slice(0, 10),
Expand All @@ -182,19 +192,20 @@ function cmdLessons(o: LessonsOpts): void {
l.commit_hash ? c.cyan(l.commit_hash.slice(0, 7)) : "",
);
}
if (hasShared) row.push(l.imported ? (l.shared_by ?? c.dim("anonymous")) : "");
if (hasShared) row.push(l.imported ? senderLabel(l.shared_by) : "");
return row;
});
log(renderTable("Lessons", columns, rows));
readOnlyNotice(compat);
}

function cmdLesson(id: string): void {
const lesson = db.withConnection((conn) => db.getLessonById(conn, id));
if (lesson === null) {
const stored = db.withConnection((conn) => db.getLessonById(conn, id));
if (stored === null) {
log(c.red(`Lesson '${id}' not found.`));
process.exit(1);
}
const lesson = printable(stored);
log(rule(c.bold(lesson.title)));
log(`${c.dim("ID:")} ${lesson.id}`);
log(`${c.dim("Date:")} ${lesson.timestamp.slice(0, 19).replace("T", " ")}`);
Expand All @@ -205,7 +216,7 @@ function cmdLesson(id: string): void {
log(
`${c.dim("Star:")} ${starLabel} ${c.dim("Feedback:")} ${feedbackLabel(lesson.feedback)}`,
);
if (lesson.imported) log(`${c.dim("Shared by:")} ${lesson.shared_by ?? c.dim("anonymous")}`);
if (lesson.imported) log(`${c.dim("Shared by:")} ${senderLabel(lesson.shared_by)}`);
if (lesson.task_context) log(`${c.dim("Context:")} ${lesson.task_context}`);
if (lesson.project || lesson.repository || lesson.branch || lesson.commit_hash || lesson.folder) {
const parts: string[] = [];
Expand Down Expand Up @@ -254,7 +265,7 @@ const courseStatusLabel = (status: string): string =>
: c.cyan(status);

function cmdCourses(): void {
const list = db.withConnection((conn) => courses.listCourses(conn));
const list = db.withConnection((conn) => courses.listCourses(conn)).map(printable);
if (list.length === 0) {
log(c.dim("No courses yet. Start one in your agent: /devcoach:course, or say yes after a ❌."));
return;
Expand All @@ -280,11 +291,12 @@ function cmdCourses(): void {
}

function cmdCourse(id: string): void {
const course = db.withConnection((conn) => courses.getCourse(conn, id));
if (course === null) {
const stored = db.withConnection((conn) => courses.getCourse(conn, id));
if (stored === null) {
log(c.red(`Course '${id}' not found.`));
process.exit(1);
}
const course = printable(stored);
const p = courses.progress(course);
log(rule(c.bold(course.title)));
log(`${c.dim("ID:")} ${course.id}`);
Expand Down Expand Up @@ -334,9 +346,9 @@ function cmdFeedback(id: string, feedback: string): void {
confLabel = "feedback cleared";
} else if (result.delta !== 0 && result.confidence !== null) {
const oldConf = result.confidence - result.delta;
confLabel = `${c.cyan(result.topic_id)} confidence: ${oldConf} → ${c.bold(String(result.confidence))}`;
confLabel = `${c.cyan(sanitizeLine(result.topic_id))} confidence: ${oldConf} → ${c.bold(String(result.confidence))}`;
} else {
confLabel = `${c.cyan(result.topic_id)} confidence unchanged`;
confLabel = `${c.cyan(sanitizeLine(result.topic_id))} confidence unchanged`;
}
log(`Lesson ${c.cyan(id)} → ${feedbackLabel(feedbackValue)} (${confLabel})`);
}
Expand Down Expand Up @@ -384,6 +396,7 @@ function cmdStats(): void {
),
);

const confidenceNote = (confidence: number): string => c.dim(`(${confidence})`);
const weakest = (stats.weakest_topics ?? []) as { topic: string; confidence: number }[];
const strongest = (stats.strongest_topics ?? []) as { topic: string; confidence: number }[];
if (weakest.length || strongest.length) {
Expand All @@ -392,8 +405,8 @@ function cmdStats(): void {
const w = weakest[i];
const s = strongest[i];
rows.push([
w ? `${c.red(w.topic)} ${c.dim(`(${w.confidence})`)}` : "",
s ? `${c.green(s.topic)} ${c.dim(`(${s.confidence})`)}` : "",
w ? `${c.red(sanitizeLine(w.topic))} ${confidenceNote(w.confidence)}` : "",
s ? `${c.green(sanitizeLine(s.topic))} ${confidenceNote(s.confidence)}` : "",
]);
}
log(
Expand Down Expand Up @@ -586,7 +599,7 @@ function cmdShare(id: string | undefined, o: ShareOpts): void {
} else if (o.link) {
log(renderShareLink(payload));
} else {
log(renderShareText(payload));
log(sanitizeForTerminal(renderShareText(payload)));
if (encodeShareCode(payload).length > 16_000) {
note("Long lesson — prefer --file for a friendlier hand-off.");
}
Expand Down Expand Up @@ -648,11 +661,11 @@ async function cmdImport(source: string | undefined): Promise<void> {
log(`${c.green("✓")} Lessons: ${parts.join(", ")}`);
return;
}
const lesson = result.lesson;
if (!lesson) {
if (!result.lesson) {
log(c.red("Could not store the lesson."));
process.exit(1);
}
const lesson = printable(result.lesson);
const who = lesson.shared_by ? `shared by ${lesson.shared_by}` : "shared anonymously";
if (result.duplicated) {
log(`${c.yellow("Already in your log")} as ${c.cyan(lesson.id)} (${who})`);
Expand Down
4 changes: 2 additions & 2 deletions src/cli/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
resolveCommand,
type ServerCommand,
} from "./mcp-command";
import { c } from "./term";
import { c, sanitizeLine } from "./term";

const log = (s = ""): void => {
console.log(s);
Expand Down Expand Up @@ -888,7 +888,7 @@ export function cmdDoctor(): void {
);
const cue = db.getCueState(conn);
if (cue.pending) warn(`a cue is pending since ${cue.last_cue_at} (retry threshold armed)`);
else if (cue.last_skip_reason) log(` last skip: "${cue.last_skip_reason}"`);
else if (cue.last_skip_reason) log(` last skip: "${sanitizeLine(cue.last_skip_reason)}"`);
const rate = coach.checkRateLimit(conn);
if (rate.allowed) ok("rate limit: allowed");
else warn(`rate limit: ${rate.reason}`);
Expand Down
Loading
Loading