@este.systems/dsc 0.2.2 → 0.4.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/dist/tui.js CHANGED
@@ -2,21 +2,28 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
  import { App } from "./tui/App.js";
4
4
  import { getState, setState } from "./store.js";
5
- import { AVAILABLE_MODELS, DEFAULT_MODEL, DeepSeekError, computeCostUsd, configPath, hasApiKey, recordUsage, } from "./api.js";
5
+ import { AVAILABLE_MODELS, DEFAULT_MODEL, DeepSeekError, apiKeySource, computeCostUsd, configPath, hasApiKey, recordUsage, saveApiKey, saveSearchKey, } from "./api.js";
6
+ import { getProviderKey } from "./search.js";
6
7
  import { runAgent, formatCost, estimateContextTokens, } from "./agent.js";
7
8
  import * as history from "./history.js";
8
9
  import * as approval from "./approval.js";
9
10
  import * as audit from "./audit.js";
10
11
  import * as replHistory from "./repl_history.js";
11
12
  import { promises as fsp } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import * as path from "node:path";
12
15
  import { compactSession } from "./compact.js";
13
16
  import { formatVersionInfo } from "./version.js";
14
17
  import { openEditor } from "./editor.js";
18
+ import { checkForUpdate, runUpdate } from "./update.js";
19
+ import { copyToClipboard } from "./clipboard.js";
15
20
  const AUTO_COMPACT_AT_TOKENS = Number(process.env.DSC_AUTO_COMPACT ?? "0") || 0;
16
21
  const AUTO_COMPACT_KEEP = Number(process.env.DSC_AUTO_COMPACT_KEEP ?? "4") || 4;
17
- // Minimal arg parsing for the TUI entry. Mirrors the subset of flags the
18
- // REPL accepts that meaningfully affect a one-shot turn. Anything fancier
19
- // (e.g. --model, --resume <id>) still has to go through --repl for now.
22
+ class CliError extends Error {
23
+ }
24
+ // Full arg parsing for the TUI entry this is the only entry point now
25
+ // that the readline REPL is gone. Anything the old REPL accepted needs to
26
+ // be handled here.
20
27
  function parseArgs(argv) {
21
28
  const out = {};
22
29
  const positional = [];
@@ -30,9 +37,24 @@ function parseArgs(argv) {
30
37
  out.yolo = true;
31
38
  else if (a === "--no-resume")
32
39
  out.noResume = true;
40
+ else if (a === "--resume") {
41
+ const v = argv[i + 1];
42
+ if (v && !v.startsWith("-")) {
43
+ out.resumeId = v;
44
+ i++;
45
+ }
46
+ out.resume = true;
47
+ }
48
+ else if (a === "--model" || a === "-m") {
49
+ const v = argv[++i];
50
+ if (!AVAILABLE_MODELS.includes(v)) {
51
+ throw new CliError(`unknown model: ${v} (available: ${AVAILABLE_MODELS.join(", ")})`);
52
+ }
53
+ out.model = v;
54
+ out.modelExplicit = true;
55
+ }
33
56
  else if (a.startsWith("-")) {
34
- // Unknown flags fall through silently — bin routes --repl-only flags
35
- // (--model, --resume <id>) to index.ts instead.
57
+ throw new CliError(`unknown flag: ${a}`);
36
58
  }
37
59
  else
38
60
  positional.push(a);
@@ -42,41 +64,59 @@ function parseArgs(argv) {
42
64
  return out;
43
65
  }
44
66
  async function main() {
45
- const cli = parseArgs(process.argv.slice(2));
67
+ let cli;
68
+ try {
69
+ cli = parseArgs(process.argv.slice(2));
70
+ }
71
+ catch (e) {
72
+ if (e instanceof CliError) {
73
+ process.stderr.write(`${e.message}\n`);
74
+ process.exit(2);
75
+ }
76
+ throw e;
77
+ }
46
78
  if (cli.version) {
47
79
  process.stdout.write(formatVersionInfo() + "\n");
48
80
  process.exit(0);
49
81
  }
50
82
  if (cli.help) {
51
83
  process.stdout.write([
52
- "dsc — CLI coding agent for DeepSeek (TUI)",
84
+ "dsc — CLI coding agent for DeepSeek",
53
85
  "",
54
86
  "Usage:",
55
- " dsc Start the TUI",
56
- " dsc \"your prompt here\" One-shot: run and exit",
57
- " dsc --repl Use the readline REPL instead",
87
+ " dsc Start the TUI",
88
+ " dsc \"your prompt here\" One-shot: run and exit",
58
89
  "",
59
- "Flags handled here:",
60
- " -y, --yolo Skip approval prompts",
61
- " --no-resume Don't auto-resume the latest session",
62
- " -v, --version Print version and exit",
63
- " -h, --help Show this help",
64
- "",
65
- "All other flags (--model, --resume <id>) go through --repl.",
90
+ "Flags:",
91
+ " -m, --model <name> " + AVAILABLE_MODELS.join(" | "),
92
+ " -y, --yolo Skip approval prompts",
93
+ " --no-resume Don't auto-resume the latest session",
94
+ " --resume [id] Resume the most recent (or by id)",
95
+ " -v, --version Print version and exit",
96
+ " -h, --help Show this help",
66
97
  ].join("\n") + "\n");
67
98
  process.exit(0);
68
99
  }
69
- if (!hasApiKey()) {
70
- process.stderr.write(`No DeepSeek API key found.\n` +
71
- `Either export DEEPSEEK_API_KEY, or create ${configPath()} containing:\n` +
72
- ` {"api_key": "sk-..."}\n`);
73
- process.exit(1);
74
- }
100
+ // First-launch UX: don't hard-exit when there's no API key. Boot the
101
+ // TUI and surface a one-line system message telling the user how to
102
+ // set one via /api-key. Submitting a turn before setting the key will
103
+ // surface DeepSeekError, which we already render as a system error.
104
+ const startupKeyMissing = !hasApiKey();
75
105
  const cwd = process.cwd();
76
106
  await history.migrateLegacyIfPresent(cwd, DEFAULT_MODEL);
77
- // Auto-resume most recent for cwd unless --no-resume; otherwise fresh.
78
107
  let session = history.newSession(cwd, DEFAULT_MODEL);
79
- if (!cli.noResume) {
108
+ if (cli.resumeId) {
109
+ // Explicit --resume <id>: load that session by id, error out if it
110
+ // doesn't exist rather than silently falling back to a new session.
111
+ const loaded = await history.loadSession(cli.resumeId);
112
+ if (!loaded) {
113
+ process.stderr.write(`session not found: ${cli.resumeId}\n`);
114
+ process.exit(1);
115
+ }
116
+ session = loaded;
117
+ }
118
+ else if (!cli.noResume) {
119
+ // Auto-resume most recent for this cwd; otherwise fresh.
80
120
  const target = await history.mostRecentForCwd(cwd);
81
121
  if (target) {
82
122
  const loaded = await history.loadSession(target.id);
@@ -88,13 +128,17 @@ async function main() {
88
128
  // out for the new session's arrays without restarting the process.
89
129
  let messages = session.messages;
90
130
  let stats = session.stats;
91
- let model = session.model;
131
+ // --model on the command line overrides the session's stored model.
132
+ // Without --model, the session's model wins so resuming preserves what
133
+ // the user picked last time.
134
+ let model = cli.modelExplicit && cli.model ? cli.model : session.model;
92
135
  const initialAutoContinue = Number(process.env.DSC_AUTO_CONTINUE ?? "0") || 0;
93
136
  const toolCtx = {
94
137
  cwd,
95
138
  yolo: !!cli.yolo,
96
139
  filesTouched: stats.files_touched,
97
140
  sessionId: session.id,
141
+ sessionApprovals: new Set(),
98
142
  };
99
143
  // Coalescing save (same shape as REPL).
100
144
  let savePromise = null;
@@ -166,15 +210,16 @@ async function main() {
166
210
  // 1-second tick so the session timer in StatusBar advances even when idle.
167
211
  const timerId = setInterval(syncStatus, 1000);
168
212
  // Install the approval asker — routes confirm* calls through the
169
- // ApprovalDialog component. The diff/preview body printed by confirm*
170
- // still goes to stdout and will appear above ink's dynamic frame; that's
171
- // acceptable for a first cut and gets cleaned up in a later commit.
172
- approval.setAsker((q) => new Promise((resolve) => {
213
+ // ApprovalDialog component. The structured payload (title + body + kind)
214
+ // goes into the dialog so the diff/preview renders inline in the yellow
215
+ // bordered box rather than bleeding above ink's dynamic frame.
216
+ approval.setAsker((req) => new Promise((resolve) => {
173
217
  setState({
174
218
  approval: {
175
- title: "Confirm",
176
- body: "",
177
- question: q,
219
+ title: req.title,
220
+ body: req.body ?? "",
221
+ kind: req.kind,
222
+ question: req.question,
178
223
  resolve,
179
224
  },
180
225
  });
@@ -439,11 +484,17 @@ async function main() {
439
484
  "/lang [name|off] force the model to reply in a language",
440
485
  "/auto-continue [N|off] auto-grant N extra MAX_TOOL_DEPTH budgets",
441
486
  "/cost show token usage and cost",
487
+ "/copy copy last assistant response to clipboard",
442
488
  "/version show version info",
443
489
  "/compact [keep] summarize old turns (default keep=4)",
444
490
  "/transcript dump full message log",
445
491
  "/audit [path|show N] audit log info",
446
492
  "/queue [clear] list or clear queued prompts",
493
+ "/export [path] write current session JSON for transfer",
494
+ "/import <path> load session JSON; rebinds cwd here (--keep-cwd to skip)",
495
+ "/api-key [key] show source / save api key to config file",
496
+ "/search-key [p] [key] show search-provider keys / save brave|tavily key",
497
+ "/update check npm for a newer dsc and install it",
447
498
  "/exit exit",
448
499
  ].join("\n"));
449
500
  return true;
@@ -453,6 +504,10 @@ async function main() {
453
504
  stats = session.stats;
454
505
  toolCtx.filesTouched = stats.files_touched;
455
506
  toolCtx.sessionId = session.id;
507
+ // Per-tool always-approval is conversation-scoped, so /clear has
508
+ // to drop it — otherwise the user would carry over implicit trust
509
+ // they made to a session they just walked away from.
510
+ toolCtx.sessionApprovals = new Set();
456
511
  setState({ history: [], current: null });
457
512
  info(`new session started (${session.id})`);
458
513
  syncStatus();
@@ -560,6 +615,32 @@ async function main() {
560
615
  case "cost":
561
616
  info(formatCost(stats, model));
562
617
  return true;
618
+ case "copy": {
619
+ // Copy the most recent assistant response to the OS clipboard. The
620
+ // last *assistant* (not tool, not system, not user) is what the
621
+ // user usually wants — the actual answer text.
622
+ const state = getState();
623
+ let target;
624
+ for (let i = state.history.length - 1; i >= 0; i--) {
625
+ const m = state.history[i];
626
+ if (m.role === "assistant" && m.content) {
627
+ target = m;
628
+ break;
629
+ }
630
+ }
631
+ if (!target) {
632
+ info("no assistant message to copy");
633
+ return true;
634
+ }
635
+ try {
636
+ await copyToClipboard(target.content);
637
+ info(`copied ${target.content.length} chars to clipboard`);
638
+ }
639
+ catch (e) {
640
+ info(`error: ${e.message}`);
641
+ }
642
+ return true;
643
+ }
563
644
  case "model":
564
645
  if (!arg) {
565
646
  info(`current model: ${model}`);
@@ -648,6 +729,117 @@ async function main() {
648
729
  case "version":
649
730
  info(formatVersionInfo());
650
731
  return true;
732
+ case "update": {
733
+ // Two-phase: check first so we don't run npm install when we're
734
+ // already current. Force-fetches the latest (ignores the 24h cache
735
+ // because the user explicitly asked) and reports either way.
736
+ info("checking npm for a newer version…");
737
+ try {
738
+ const check = await checkForUpdate({ force: true });
739
+ if (!check.newerAvailable) {
740
+ info(`up to date (${check.current})`);
741
+ return true;
742
+ }
743
+ info(`installing ${check.latest} (currently ${check.current}) via npm…`);
744
+ const r = await runUpdate();
745
+ if (!r.ok) {
746
+ // npm prints reasonably descriptive errors itself; pass them
747
+ // through so EACCES / permission issues are obvious.
748
+ info(`error: update failed\n${r.output.slice(-1500)}\n\n` +
749
+ `If this is a permission error, try: sudo npm install -g @este.systems/dsc@latest`);
750
+ return true;
751
+ }
752
+ info(`installed ${check.latest}. Exit and re-run \`dsc\` to pick up the new version.`);
753
+ }
754
+ catch (e) {
755
+ info(`error: update failed: ${e.message}`);
756
+ }
757
+ return true;
758
+ }
759
+ case "api-key": {
760
+ const text = arg.trim();
761
+ if (!text) {
762
+ // No arg: show where the key is configured (don't print the key
763
+ // itself; that's not the kind of thing /command output should
764
+ // splash to scrollback).
765
+ const src = apiKeySource();
766
+ if (src === "env") {
767
+ info("api key: set via $DEEPSEEK_API_KEY (env)");
768
+ }
769
+ else if (src === "file") {
770
+ info(`api key: stored in ${configPath()}`);
771
+ }
772
+ else {
773
+ info(`api key: not set\nGet one at https://platform.deepseek.com/api_keys, then run /api-key <key> (or export DEEPSEEK_API_KEY in your shell).`);
774
+ }
775
+ return true;
776
+ }
777
+ try {
778
+ const written = await saveApiKey(text);
779
+ info(`api key saved to ${written}`);
780
+ }
781
+ catch (e) {
782
+ info(`error: ${e.message}`);
783
+ }
784
+ return true;
785
+ }
786
+ case "search-key": {
787
+ // /search-key -> list providers + status + URLs
788
+ // /search-key <provider> -> show that provider's status + URL
789
+ // /search-key <provider> <key> -> save it to config
790
+ const tokens = arg.trim().split(/\s+/).filter(Boolean);
791
+ const [providerArg, ...keyParts] = tokens;
792
+ const keyArg = keyParts.join(" ");
793
+ const SIGNUP = {
794
+ brave: "https://api-dashboard.search.brave.com/app/keys",
795
+ tavily: "https://app.tavily.com/",
796
+ };
797
+ const ENV_VAR = {
798
+ brave: "BRAVE_API_KEY",
799
+ tavily: "TAVILY_API_KEY",
800
+ };
801
+ const sourceFor = (p) => {
802
+ if (process.env[ENV_VAR[p]])
803
+ return "env";
804
+ return getProviderKey(p) ? "file" : null;
805
+ };
806
+ const lineFor = (p) => {
807
+ const src = sourceFor(p);
808
+ const status = src === "env"
809
+ ? `set via $${ENV_VAR[p]}`
810
+ : src === "file"
811
+ ? "stored in config"
812
+ : "not set";
813
+ return ` ${p}: ${status} — ${SIGNUP[p]}`;
814
+ };
815
+ if (!providerArg) {
816
+ info([
817
+ "search providers:",
818
+ lineFor("brave"),
819
+ lineFor("tavily"),
820
+ "",
821
+ "To save a key: /search-key <provider> <key>",
822
+ "Pick the active provider with DSC_SEARCH_PROVIDER or `search.provider` in the config.",
823
+ ].join("\n"));
824
+ return true;
825
+ }
826
+ if (providerArg !== "brave" && providerArg !== "tavily") {
827
+ info(`error: unknown provider '${providerArg}' (expected: brave | tavily)`);
828
+ return true;
829
+ }
830
+ if (!keyArg) {
831
+ info(`${providerArg}: ${sourceFor(providerArg) ?? "not set"}\nsignup: ${SIGNUP[providerArg]}`);
832
+ return true;
833
+ }
834
+ try {
835
+ const written = await saveSearchKey(providerArg, keyArg);
836
+ info(`${providerArg} key saved to ${written}`);
837
+ }
838
+ catch (e) {
839
+ info(`error: ${e.message}`);
840
+ }
841
+ return true;
842
+ }
651
843
  case "audit": {
652
844
  const sub = arg.trim();
653
845
  if (!sub || sub === "path") {
@@ -712,6 +904,70 @@ async function main() {
712
904
  await runCompaction(keep, false);
713
905
  return true;
714
906
  }
907
+ case "export": {
908
+ // `/export [path]` — writes the current session JSON. Relative
909
+ // paths resolve against the launch cwd, not against the session's
910
+ // recorded cwd, so it lands where the user actually is. Default
911
+ // target is the cwd, named after /save'd name (or id) and date.
912
+ const dest = arg.trim() || ".";
913
+ const absDest = path.isAbsolute(dest) ? dest : path.resolve(cwd, dest);
914
+ try {
915
+ await persist(); // flush any pending writes before export
916
+ const written = await history.exportSession(session.id, absDest);
917
+ info(`exported to ${written}`);
918
+ }
919
+ catch (e) {
920
+ info(`error: export failed: ${e.message}`);
921
+ }
922
+ return true;
923
+ }
924
+ case "import": {
925
+ // `/import <path>` — reads a session JSON, rebinds its cwd to the
926
+ // current directory so auto-resume picks it up here, and loads it
927
+ // as the active session immediately. Keep the previous cwd with
928
+ // `--keep-cwd` (positional flag, kept simple).
929
+ const tokens = arg.trim().split(/\s+/).filter(Boolean);
930
+ const keepCwd = tokens.includes("--keep-cwd");
931
+ const file = tokens.find((t) => !t.startsWith("--"));
932
+ if (!file) {
933
+ info("error: usage: /import <path> [--keep-cwd]");
934
+ return true;
935
+ }
936
+ const absFile = path.isAbsolute(file) ? file : path.resolve(cwd, file);
937
+ try {
938
+ const loaded = await history.importSession(absFile, {
939
+ rebindCwd: keepCwd ? undefined : cwd,
940
+ });
941
+ session = loaded;
942
+ messages = session.messages;
943
+ stats = session.stats;
944
+ model = session.model;
945
+ toolCtx.filesTouched = stats.files_touched;
946
+ toolCtx.sessionId = session.id;
947
+ // Refill the visible history so the user sees the imported
948
+ // conversation right away (same logic the /resume path uses).
949
+ const restored = [];
950
+ for (const m of messages) {
951
+ if (m.role === "system")
952
+ continue;
953
+ restored.push({
954
+ id: `r-${restored.length}`,
955
+ role: m.role,
956
+ content: typeof m.content === "string" ? m.content : "",
957
+ tool_call_id: m.tool_call_id,
958
+ });
959
+ }
960
+ setState({ history: restored, current: null, model });
961
+ syncStatus();
962
+ const userTurns = messages.filter((m) => m.role === "user").length;
963
+ const cwdNote = keepCwd ? "" : " (cwd rebound to here)";
964
+ info(`imported ${session.id} (${userTurns} turns, model ${model})${cwdNote}`);
965
+ }
966
+ catch (e) {
967
+ info(`error: import failed: ${e.message}`);
968
+ }
969
+ return true;
970
+ }
715
971
  case "edit": {
716
972
  // ink owns the terminal — unmount before spawning $EDITOR so the
717
973
  // editor gets a clean screen. We also clear `history` before the
@@ -747,6 +1003,16 @@ async function main() {
747
1003
  void replHistory.append(text);
748
1004
  }
749
1005
  if (text.startsWith("/")) {
1006
+ // Echo the command into history so the user can see which command
1007
+ // produced the system-line output that follows. Without this the
1008
+ // info() lines look orphaned — especially when you scroll back
1009
+ // and there's no record of what triggered each one.
1010
+ setState((s) => ({
1011
+ history: [
1012
+ ...s.history,
1013
+ { id: `u-${s.history.length}`, role: "user", content: text },
1014
+ ],
1015
+ }));
750
1016
  void handleSlash(text);
751
1017
  return;
752
1018
  }
@@ -793,6 +1059,63 @@ async function main() {
793
1059
  process.exit(0);
794
1060
  }
795
1061
  mountApp();
1062
+ // One-time welcome panel. Stamped via a touch-file under XDG_STATE_HOME
1063
+ // so we never nag the same user twice. New users get an orientation
1064
+ // (key, /help, hotkeys); the more-targeted "no API key" reminder fires
1065
+ // on every subsequent launch when no key is configured.
1066
+ const stateDir = (() => {
1067
+ const xdg = process.env.XDG_STATE_HOME;
1068
+ const base = xdg && xdg.length ? xdg : path.join(homedir(), ".local", "state");
1069
+ return path.join(base, "dsc");
1070
+ })();
1071
+ const welcomeStamp = path.join(stateDir, "welcomed");
1072
+ let alreadyWelcomed = false;
1073
+ try {
1074
+ await fsp.access(welcomeStamp);
1075
+ alreadyWelcomed = true;
1076
+ }
1077
+ catch {
1078
+ // first launch
1079
+ }
1080
+ if (!alreadyWelcomed) {
1081
+ const lines = [
1082
+ "Welcome to dsc — a CLI coding agent for DeepSeek.",
1083
+ "",
1084
+ " /help full command list (TAB completes any /command)",
1085
+ " Up / Down recall past prompts",
1086
+ " ESC abort a running turn",
1087
+ " Ctrl+D exit",
1088
+ ];
1089
+ if (startupKeyMissing) {
1090
+ lines.push("", "To get started, save your API key:", " /api-key sk-... (get one at https://platform.deepseek.com/api_keys)", " or: export DEEPSEEK_API_KEY in your shell");
1091
+ }
1092
+ info(lines.join("\n"));
1093
+ try {
1094
+ await fsp.mkdir(stateDir, { recursive: true });
1095
+ await fsp.writeFile(welcomeStamp, new Date().toISOString(), "utf8");
1096
+ }
1097
+ catch {
1098
+ // best-effort; missing perms just means the welcome shows again
1099
+ }
1100
+ }
1101
+ else if (startupKeyMissing) {
1102
+ // Targeted nudge for returning users who somehow lost their key.
1103
+ info(`No DeepSeek API key found. Run "/api-key <key>" to save one to ${configPath()}, or export DEEPSEEK_API_KEY in your shell.`);
1104
+ }
1105
+ // Fire-and-forget update probe. Uses the 24h cache by default so a
1106
+ // chatty notice doesn't appear on every launch — only once per day,
1107
+ // when the registry says we're behind. Failures are swallowed silently.
1108
+ void (async () => {
1109
+ try {
1110
+ const check = await checkForUpdate();
1111
+ if (check.newerAvailable) {
1112
+ info(`update available: ${check.latest} (you have ${check.current}). Run /update to install.`);
1113
+ }
1114
+ }
1115
+ catch {
1116
+ // best-effort; offline launches shouldn't yell at the user
1117
+ }
1118
+ })();
796
1119
  }
797
1120
  function truncate(s, n) {
798
1121
  return s.length <= n ? s : s.slice(0, n) + "…";