dogear 0.1.0__py3-none-any.whl
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.
- dogear/__init__.py +3 -0
- dogear/__main__.py +5 -0
- dogear/agents/__init__.py +81 -0
- dogear/agents/claude.py +134 -0
- dogear/agents/codex.py +113 -0
- dogear/assets/claude_skill.md +35 -0
- dogear/assets/codex_openai.yaml +2 -0
- dogear/assets/codex_skill.md +32 -0
- dogear/cli.py +499 -0
- dogear/config.py +54 -0
- dogear/detect.py +33 -0
- dogear/errors.py +2 -0
- dogear/favorites.py +84 -0
- dogear/jsonl.py +25 -0
- dogear/launch.py +40 -0
- dogear/paths.py +36 -0
- dogear/picker.py +61 -0
- dogear/proctree.py +227 -0
- dogear/render.py +91 -0
- dogear/setup_cmd.py +47 -0
- dogear/store.py +134 -0
- dogear-0.1.0.dist-info/METADATA +181 -0
- dogear-0.1.0.dist-info/RECORD +26 -0
- dogear-0.1.0.dist-info/WHEEL +4 -0
- dogear-0.1.0.dist-info/entry_points.txt +2 -0
- dogear-0.1.0.dist-info/licenses/LICENSE +21 -0
dogear/__init__.py
ADDED
dogear/__main__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Agent adapters. Adding an agent means adding one module with an Adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Protocol
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from dogear.proctree import ProcessTable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Session:
|
|
15
|
+
agent: str
|
|
16
|
+
session_id: str
|
|
17
|
+
cwd: str
|
|
18
|
+
title: str
|
|
19
|
+
mtime: float
|
|
20
|
+
path: Path | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Adapter(Protocol):
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
def find(self, prefix: str) -> list[Session]: ...
|
|
27
|
+
def get(self, session_id: str) -> Session | None: ...
|
|
28
|
+
def exists(self, session_id: str) -> bool: ...
|
|
29
|
+
def recent(self, limit: int) -> list[Session]: ...
|
|
30
|
+
def is_agent_process(self, command: str) -> bool: ...
|
|
31
|
+
def detect(self, pid: int, table: "ProcessTable") -> list[Session]: ...
|
|
32
|
+
def resume_args(self, session_id: str) -> list[str]: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def command_basename(command: str) -> str:
|
|
36
|
+
"""'C:\\x\\claude.exe' -> 'claude'; '-zsh' -> 'zsh' (login shells)."""
|
|
37
|
+
base = command.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
|
|
38
|
+
if base.lower().endswith(".exe"):
|
|
39
|
+
base = base[:-4]
|
|
40
|
+
return base.lstrip("-")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def newest_by_id(paths_with_ids: list[tuple[str, Path]]) -> dict[str, Path]:
|
|
44
|
+
"""Several files can exist for one session id; keep the newest (spec §11b)."""
|
|
45
|
+
best: dict[str, tuple[float, Path]] = {}
|
|
46
|
+
for sid, p in paths_with_ids:
|
|
47
|
+
try:
|
|
48
|
+
m = p.stat().st_mtime
|
|
49
|
+
except OSError:
|
|
50
|
+
continue
|
|
51
|
+
if sid not in best or m > best[sid][0]:
|
|
52
|
+
best[sid] = (m, p)
|
|
53
|
+
return {sid: p for sid, (m, p) in best.items()}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def newest_first(by_id: dict[str, Path], limit: int) -> list[tuple[str, Path]]:
|
|
57
|
+
"""Sort by mtime without failing when a file disappears meanwhile (agents prune old ones)."""
|
|
58
|
+
stamped = []
|
|
59
|
+
for sid, p in by_id.items():
|
|
60
|
+
try:
|
|
61
|
+
stamped.append((p.stat().st_mtime, sid, p))
|
|
62
|
+
except OSError:
|
|
63
|
+
continue
|
|
64
|
+
stamped.sort(key=lambda t: t[0], reverse=True)
|
|
65
|
+
return [(sid, p) for _, sid, p in stamped[:limit]]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_adapters() -> dict[str, Adapter]:
|
|
69
|
+
from dogear.agents.claude import ClaudeAdapter
|
|
70
|
+
from dogear.agents.codex import CodexAdapter
|
|
71
|
+
|
|
72
|
+
return {"claude": ClaudeAdapter(), "codex": CodexAdapter()}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def find_sessions(prefix: str, agent: str | None = None) -> list[Session]:
|
|
76
|
+
out: list[Session] = []
|
|
77
|
+
prefix = prefix.lower() # session ids are lowercase UUIDs
|
|
78
|
+
for name, adapter in get_adapters().items():
|
|
79
|
+
if agent is None or agent == name:
|
|
80
|
+
out.extend(adapter.find(prefix))
|
|
81
|
+
return out
|
dogear/agents/claude.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Claude Code session store (read-only). Internal files observed on Claude Code 2.1.x."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dogear.agents import Session, command_basename, newest_by_id, newest_first
|
|
11
|
+
from dogear.paths import claude_home
|
|
12
|
+
|
|
13
|
+
TITLE_LEN = 60
|
|
14
|
+
SESSION_FILE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _prompt_text(message: object) -> str:
|
|
18
|
+
if not isinstance(message, dict):
|
|
19
|
+
return ""
|
|
20
|
+
content = message.get("content")
|
|
21
|
+
if isinstance(content, str):
|
|
22
|
+
text = content
|
|
23
|
+
elif isinstance(content, list):
|
|
24
|
+
text = next(
|
|
25
|
+
(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"),
|
|
26
|
+
"",
|
|
27
|
+
)
|
|
28
|
+
else:
|
|
29
|
+
return ""
|
|
30
|
+
text = " ".join(str(text).split())
|
|
31
|
+
if text.startswith("<"): # command wrappers and other meta content, not a real prompt
|
|
32
|
+
return ""
|
|
33
|
+
return text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _shorten(text: str) -> str:
|
|
37
|
+
return text if len(text) <= TITLE_LEN else text[: TITLE_LEN - 1].rstrip() + "…"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ClaudeAdapter:
|
|
41
|
+
name = "claude"
|
|
42
|
+
|
|
43
|
+
def __init__(self, home: Path | None = None):
|
|
44
|
+
self.home = home or claude_home()
|
|
45
|
+
|
|
46
|
+
# store -----------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _transcripts(self, prefix: str = "") -> dict[str, Path]:
|
|
49
|
+
# only the part below the store root is a pattern; the root may contain [ ] * ?
|
|
50
|
+
pattern = f"*/{glob.escape(prefix)}*.jsonl"
|
|
51
|
+
# sub-agent and other side files (e.g. agent-*.jsonl) are not resumable sessions
|
|
52
|
+
found = [(p.stem, p) for p in (self.home / "projects").glob(pattern) if SESSION_FILE.match(p.stem)]
|
|
53
|
+
return newest_by_id(found)
|
|
54
|
+
|
|
55
|
+
def _session(self, sid: str, path: Path) -> Session:
|
|
56
|
+
custom = ai = prompt = ""
|
|
57
|
+
cwd = ""
|
|
58
|
+
for obj in self._wanted_lines(path, lambda: not cwd, lambda: not prompt):
|
|
59
|
+
t = obj.get("type")
|
|
60
|
+
if not cwd and isinstance(obj.get("cwd"), str):
|
|
61
|
+
cwd = obj["cwd"]
|
|
62
|
+
if t == "custom-title" and isinstance(obj.get("customTitle"), str):
|
|
63
|
+
custom = obj["customTitle"]
|
|
64
|
+
elif t == "ai-title" and isinstance(obj.get("aiTitle"), str):
|
|
65
|
+
ai = obj["aiTitle"]
|
|
66
|
+
elif t == "user" and not prompt and not obj.get("isMeta"):
|
|
67
|
+
prompt = _prompt_text(obj.get("message"))
|
|
68
|
+
title = custom.strip() or ai.strip() or (_shorten(prompt) if prompt else "(untitled)")
|
|
69
|
+
try:
|
|
70
|
+
mtime = path.stat().st_mtime
|
|
71
|
+
except OSError:
|
|
72
|
+
mtime = 0.0
|
|
73
|
+
return Session("claude", sid, cwd, title, mtime, path)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _wanted_lines(path: Path, need_cwd, need_prompt):
|
|
77
|
+
"""Parse only the lines that can matter: transcripts are large and most lines
|
|
78
|
+
are messages, so skip JSON-decoding them once cwd and the first prompt are known."""
|
|
79
|
+
try:
|
|
80
|
+
f = path.open("rb")
|
|
81
|
+
except OSError:
|
|
82
|
+
return
|
|
83
|
+
with f:
|
|
84
|
+
for raw in f:
|
|
85
|
+
if not (b'-title"' in raw or need_cwd() or (need_prompt() and b'"user"' in raw)):
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
obj = json.loads(raw)
|
|
89
|
+
except ValueError:
|
|
90
|
+
continue
|
|
91
|
+
if isinstance(obj, dict):
|
|
92
|
+
yield obj
|
|
93
|
+
|
|
94
|
+
def find(self, prefix: str) -> list[Session]:
|
|
95
|
+
return [self._session(sid, p) for sid, p in sorted(self._transcripts(prefix).items())]
|
|
96
|
+
|
|
97
|
+
def get(self, session_id: str) -> Session | None:
|
|
98
|
+
p = self._transcripts(session_id).get(session_id)
|
|
99
|
+
return self._session(session_id, p) if p else None
|
|
100
|
+
|
|
101
|
+
def exists(self, session_id: str) -> bool:
|
|
102
|
+
return session_id in self._transcripts(session_id)
|
|
103
|
+
|
|
104
|
+
def recent(self, limit: int) -> list[Session]:
|
|
105
|
+
return [self._session(sid, p) for sid, p in newest_first(self._transcripts(), limit)]
|
|
106
|
+
|
|
107
|
+
# processes -------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def is_agent_process(self, command: str) -> bool:
|
|
110
|
+
norm = command.replace("\\", "/")
|
|
111
|
+
return (
|
|
112
|
+
command_basename(command) == "claude"
|
|
113
|
+
or "/claude/versions/" in norm
|
|
114
|
+
or "/@anthropic-ai/claude-code/" in norm
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def detect(self, pid: int, table) -> list[Session]:
|
|
118
|
+
"""Claude writes sessions/<pid>.json for each live process; stale files are ignored."""
|
|
119
|
+
try:
|
|
120
|
+
live = json.loads((self.home / "sessions" / f"{pid}.json").read_text(encoding="utf-8"))
|
|
121
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
122
|
+
return []
|
|
123
|
+
if not isinstance(live, dict) or live.get("pid") != pid or not isinstance(live.get("sessionId"), str):
|
|
124
|
+
return []
|
|
125
|
+
sid = live["sessionId"]
|
|
126
|
+
found = self.get(sid)
|
|
127
|
+
if found:
|
|
128
|
+
return [found]
|
|
129
|
+
cwd = live.get("cwd") if isinstance(live.get("cwd"), str) else ""
|
|
130
|
+
name = live.get("name") if isinstance(live.get("name"), str) and live.get("name") else "(untitled)"
|
|
131
|
+
return [Session("claude", sid, cwd, name, 0.0, None)]
|
|
132
|
+
|
|
133
|
+
def resume_args(self, session_id: str) -> list[str]:
|
|
134
|
+
return ["--resume", session_id]
|
dogear/agents/codex.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Codex CLI session store (read-only). Internal files observed on codex-cli 0.16x."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from dogear.agents import Session, command_basename, newest_by_id, newest_first
|
|
10
|
+
from dogear.jsonl import iter_jsonl
|
|
11
|
+
from dogear.paths import codex_home
|
|
12
|
+
|
|
13
|
+
UUID_LEN = 36
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _first_line(path: Path) -> dict:
|
|
17
|
+
try:
|
|
18
|
+
with path.open("r", encoding="utf-8", errors="replace") as f:
|
|
19
|
+
obj = json.loads(f.readline())
|
|
20
|
+
except (OSError, json.JSONDecodeError):
|
|
21
|
+
return {}
|
|
22
|
+
return obj if isinstance(obj, dict) else {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CodexAdapter:
|
|
26
|
+
name = "codex"
|
|
27
|
+
|
|
28
|
+
def __init__(self, home: Path | None = None):
|
|
29
|
+
self.home = home or codex_home()
|
|
30
|
+
|
|
31
|
+
# store -----------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def _rollouts(self, prefix: str = "") -> dict[str, Path]:
|
|
34
|
+
# only the part below the store root is a pattern; the root may contain [ ] * ?
|
|
35
|
+
found = []
|
|
36
|
+
for p in (self.home / "sessions").glob("*/*/*/rollout-*.jsonl"):
|
|
37
|
+
sid = p.stem[-UUID_LEN:]
|
|
38
|
+
if sid.startswith(prefix):
|
|
39
|
+
found.append((sid, p))
|
|
40
|
+
return newest_by_id(found)
|
|
41
|
+
|
|
42
|
+
def _titles(self) -> dict[str, str]:
|
|
43
|
+
titles: dict[str, str] = {}
|
|
44
|
+
for obj in iter_jsonl(self.home / "session_index.jsonl"):
|
|
45
|
+
sid, name = obj.get("id"), obj.get("thread_name")
|
|
46
|
+
if isinstance(sid, str) and isinstance(name, str) and name.strip():
|
|
47
|
+
titles[sid] = name.strip()
|
|
48
|
+
return titles
|
|
49
|
+
|
|
50
|
+
def _session(self, sid: str, path: Path, titles: dict[str, str]) -> Session:
|
|
51
|
+
meta = _first_line(path)
|
|
52
|
+
payload = meta.get("payload") if meta.get("type") == "session_meta" else None
|
|
53
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
54
|
+
if isinstance(payload.get("id"), str):
|
|
55
|
+
sid = payload["id"]
|
|
56
|
+
cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else ""
|
|
57
|
+
try:
|
|
58
|
+
mtime = path.stat().st_mtime
|
|
59
|
+
except OSError:
|
|
60
|
+
mtime = 0.0
|
|
61
|
+
return Session("codex", sid, cwd, titles.get(sid, "(untitled)"), mtime, path)
|
|
62
|
+
|
|
63
|
+
def find(self, prefix: str) -> list[Session]:
|
|
64
|
+
titles = self._titles()
|
|
65
|
+
return [self._session(sid, p, titles) for sid, p in sorted(self._rollouts(prefix).items())]
|
|
66
|
+
|
|
67
|
+
def get(self, session_id: str) -> Session | None:
|
|
68
|
+
p = self._rollouts(session_id).get(session_id)
|
|
69
|
+
return self._session(session_id, p, self._titles()) if p else None
|
|
70
|
+
|
|
71
|
+
def exists(self, session_id: str) -> bool:
|
|
72
|
+
return session_id in self._rollouts(session_id)
|
|
73
|
+
|
|
74
|
+
def recent(self, limit: int) -> list[Session]:
|
|
75
|
+
newest = newest_first(self._rollouts(), limit)
|
|
76
|
+
titles = self._titles()
|
|
77
|
+
return [self._session(sid, p, titles) for sid, p in newest]
|
|
78
|
+
|
|
79
|
+
# processes -------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def is_agent_process(self, command: str) -> bool:
|
|
82
|
+
return command_basename(command) == "codex"
|
|
83
|
+
|
|
84
|
+
def detect(self, pid: int, table) -> list[Session]:
|
|
85
|
+
"""The rollout the process has open; else rollouts in its cwd written since it started."""
|
|
86
|
+
sessions_dir = os.path.realpath(self.home / "sessions")
|
|
87
|
+
open_ids = []
|
|
88
|
+
for f in table.open_files(pid):
|
|
89
|
+
real = os.path.realpath(f)
|
|
90
|
+
name = os.path.basename(real)
|
|
91
|
+
if real.startswith(sessions_dir + os.sep) and name.startswith("rollout-") and name.endswith(".jsonl"):
|
|
92
|
+
open_ids.append(name[: -len(".jsonl")][-UUID_LEN:])
|
|
93
|
+
if open_ids:
|
|
94
|
+
return [s for sid in dict.fromkeys(open_ids) if (s := self.get(sid))]
|
|
95
|
+
cwd, start = table.cwd(pid), table.start_time(pid)
|
|
96
|
+
if not cwd or start is None:
|
|
97
|
+
return []
|
|
98
|
+
real_cwd = os.path.realpath(cwd)
|
|
99
|
+
titles = self._titles()
|
|
100
|
+
out = []
|
|
101
|
+
for sid, p in sorted(self._rollouts().items()):
|
|
102
|
+
try:
|
|
103
|
+
if p.stat().st_mtime < start:
|
|
104
|
+
continue
|
|
105
|
+
except OSError:
|
|
106
|
+
continue
|
|
107
|
+
s = self._session(sid, p, titles)
|
|
108
|
+
if s.cwd and os.path.realpath(s.cwd) == real_cwd:
|
|
109
|
+
out.append(s)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
def resume_args(self, session_id: str) -> list[str]:
|
|
113
|
+
return ["resume", session_id]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dogear
|
|
3
|
+
description: Save the current Claude Code session as a dogear favourite (bookmark) so it can be listed and resumed later from any folder. Use when the user runs /dogear or explicitly asks to dogear, bookmark or favourite this session. Not for browser bookmarks or bookmarks in code.
|
|
4
|
+
argument-hint: "[name] [tags, note, launch flags — plain words are fine]"
|
|
5
|
+
allowed-tools: Bash(command -v dogear), Bash(where dogear), Bash(dogear add *), Bash(dogear rename *)
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Save this session as a dogear favourite.
|
|
9
|
+
|
|
10
|
+
The user's request: $ARGUMENTS
|
|
11
|
+
(If that is empty, use what the user just asked for; it may be nothing beyond "save it".)
|
|
12
|
+
|
|
13
|
+
1. Check that the `dogear` command is installed: run `command -v dogear`
|
|
14
|
+
(on Windows, `where dogear`).
|
|
15
|
+
2. If it is not installed, reply with exactly this line and stop:
|
|
16
|
+
`dogear is not installed. Install it with: uv tool install dogear (or pipx install dogear)`
|
|
17
|
+
3. Turn the request into one `dogear add` command:
|
|
18
|
+
- Already in flag form (`my-label -t work -n "note"`): use it unchanged.
|
|
19
|
+
- A name or label: put it first, as one word (lowercase, hyphens instead of
|
|
20
|
+
spaces, not only digits).
|
|
21
|
+
- Tags: one `-t TAG` per tag. A note: `-n "TEXT"`. Launch flags such as a model:
|
|
22
|
+
`--args "FLAGS"`. Quote any value with spaces.
|
|
23
|
+
- Nothing given: plain `dogear add`.
|
|
24
|
+
- Never add a session id unless the user gave one.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
- "save with name testtest" → `dogear add testtest`
|
|
28
|
+
- "call it jobs, tags work and client, note: auth half done" →
|
|
29
|
+
`dogear add jobs -t work -t client -n "auth half done"`
|
|
30
|
+
- "bookmark this with opus" → `dogear add --args "--model opus"`
|
|
31
|
+
4. Run that command once with the shell tool.
|
|
32
|
+
5. If the output says `already saved as OLD` and the user asked for a
|
|
33
|
+
different name NEW, run `dogear rename OLD NEW` once.
|
|
34
|
+
6. Reply with the commands' output verbatim and nothing else. If a command
|
|
35
|
+
fails, show its error and stop; do not retry or run anything else.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dogear
|
|
3
|
+
description: Save the current Codex session as a dogear favourite (bookmark) so it can be listed and resumed later from any folder. Use when the user invokes $dogear or explicitly asks to dogear, bookmark or favourite this session. Not for browser bookmarks or bookmarks in code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Save this session as a dogear favourite.
|
|
7
|
+
|
|
8
|
+
The user's request is whatever they typed after `$dogear`, or what they just
|
|
9
|
+
asked for; it may be nothing beyond "save it".
|
|
10
|
+
|
|
11
|
+
1. Check that the `dogear` command is installed: run `command -v dogear`
|
|
12
|
+
(on Windows, `where dogear`).
|
|
13
|
+
2. If it is not installed, reply with exactly this line and stop:
|
|
14
|
+
`dogear is not installed. Install it with: uv tool install dogear (or pipx install dogear)`
|
|
15
|
+
3. Turn the request into one `dogear add` command:
|
|
16
|
+
- Already in flag form (`my-label -t work -n "note"`): use it unchanged.
|
|
17
|
+
- A name or label: put it first, as one word (lowercase, hyphens instead of
|
|
18
|
+
spaces, not only digits).
|
|
19
|
+
- Tags: one `-t TAG` per tag. A note: `-n "TEXT"`. Launch flags: `--args "FLAGS"`.
|
|
20
|
+
Quote any value with spaces.
|
|
21
|
+
- Nothing given: plain `dogear add`.
|
|
22
|
+
- Never add a session id unless the user gave one.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
- "save with name testtest" → `dogear add testtest`
|
|
26
|
+
- "call it jobs, tags work and client, note: auth half done" →
|
|
27
|
+
`dogear add jobs -t work -t client -n "auth half done"`
|
|
28
|
+
4. Run that command once with the shell tool.
|
|
29
|
+
5. If the output says `already saved as OLD` and the user asked for a
|
|
30
|
+
different name NEW, run `dogear rename OLD NEW` once.
|
|
31
|
+
6. Reply with the commands' output verbatim and nothing else. If a command
|
|
32
|
+
fails, show its error and stop; do not retry or run anything else.
|