Skip to content

Commit 6324d27

Browse files
committed
Initial CC persona from jupyter-ai-demos.
1 parent 53cd1ea commit 6324d27

File tree

8 files changed

+280
-2
lines changed

8 files changed

+280
-2
lines changed

.gitattributes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# SCM syntax highlighting & preventing 3-way merges
2+
pixi.lock merge=binary linguist-language=YAML linguist-generated=true

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,6 @@ cython_debug/
205205
marimo/_static/
206206
marimo/_lsp/
207207
__marimo__/
208+
# pixi environments
209+
.pixi/*
210+
!.pixi/config.toml

README.md

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,64 @@
1-
# jupyter-ai-claude-code
2-
A Jupyter AI persona for Claude Code
1+
# Jupyter AI Claude Code
2+
3+
Jupyter AI integration with Claude Code.
4+
5+
## Setup
6+
7+
This project uses [pixi.sh](https://pixi.sh) for dependency management and environment setup.
8+
9+
### Prerequisites
10+
11+
Install pixi.sh:
12+
```bash
13+
curl -fsSL https://pixi.sh/install.sh | bash
14+
```
15+
16+
### Installation
17+
18+
1. Clone the repository:
19+
```bash
20+
git clone <repository-url>
21+
cd jupyter-ai-claude-code
22+
```
23+
24+
2. Install dependencies and set up the environment:
25+
```bash
26+
pixi install
27+
```
28+
29+
This will:
30+
- Install JupyterLab from conda-forge
31+
- Install Jupyter AI 3.0.0b5 from PyPI
32+
- Install the package in editable mode
33+
34+
## Usage
35+
36+
### Start JupyterLab
37+
38+
```bash
39+
pixi run start
40+
```
41+
42+
This will start JupyterLab with the Jupyter AI extension and this package available.
43+
44+
### Build the Package
45+
46+
The package is automatically installed in editable mode during `pixi install`. To manually build:
47+
48+
```bash
49+
pixi run python -m build
50+
```
51+
52+
## Development
53+
54+
The package source code is located in `src/jupyter_ai_claude_code/`.
55+
56+
## Dependencies
57+
58+
- **JupyterLab**: Latest stable version from conda-forge
59+
- **Jupyter AI**: Version 3.0.0b5 from PyPI
60+
- **Python**: >=3.8
61+
62+
## License
63+
64+
Revised BSD

pixi.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[workspace]
2+
authors = ["Brian E. Granger <ellisonbg@gmail.com>"]
3+
channels = ["conda-forge"]
4+
name = "jupyter-ai-claude-code"
5+
platforms = ["osx-arm64"]
6+
version = "0.1.0"
7+
8+
[tasks]
9+
start = "jupyter lab"
10+
11+
[dependencies]
12+
jupyterlab = ">=4.4.0,<4.5"
13+
python = ">=3.8"
14+
hatchling = "*"
15+
16+
[pypi-dependencies]
17+
jupyter-ai = ">=3.0.0b5"
18+
jupyter-ai-magics = ">=3.0.0b5"
19+
claude-code-sdk = "*"
20+
jupyter_server_documents = "*"
21+
jupyter-ai-claude-code = { path = ".", editable = true }
22+

pyproject.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "jupyter-ai-claude-code"
7+
version = "0.1.0"
8+
description = "Jupyter AI integration with Claude Code"
9+
authors = [
10+
{name = "Brian E. Granger", email = "ellisonbg@gmail.com"},
11+
]
12+
readme = "README.md"
13+
license = {text = "BSD-3-Clause"}
14+
requires-python = ">=3.8"
15+
dependencies = [
16+
"jupyter-ai>=3.0.0b5",
17+
"jupyter-ai-magics>=3.0.0b5",
18+
"claude-code-sdk",
19+
"jupyter_server_documents",
20+
]
21+
22+
[project.entry-points."jupyter_ai.personas"]
23+
claude_code = "jupyter_ai_claude_code.persona:ClaudeCodePersona"
24+
25+
[tool.hatch.build.targets.wheel]
26+
packages = ["src/jupyter_ai_claude_code"]
27+
28+
[tool.hatch.build.targets.wheel.force-include]
29+
"src/jupyter_ai_claude_code/static" = "jupyter_ai_claude_code/static"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Jupyter AI Claude Code integration package."""
2+
3+
__version__ = "0.1.0"
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from typing import Dict, Any, List, Optional, AsyncIterator
2+
3+
from jupyter_ai.personas.base_persona import BasePersona, PersonaDefaults
4+
from jupyterlab_chat.models import Message
5+
6+
from claude_code_sdk import (
7+
query, ClaudeCodeOptions,
8+
Message, SystemMessage, AssistantMessage, ResultMessage,
9+
TextBlock, ToolUseBlock
10+
)
11+
12+
13+
OMIT_INPUT_ARGS = ['content']
14+
15+
TOOL_PARAM_MAPPING = {
16+
'Task': 'description',
17+
'Bash': 'command',
18+
'Glob': 'pattern',
19+
'Grep': 'pattern',
20+
'LS': 'path',
21+
'Read': 'file_path',
22+
'Edit': 'file_path',
23+
'MultiEdit': 'file_path',
24+
'Write': 'file_path',
25+
'NotebookRead': 'notebook_path',
26+
'NotebookWrite': 'notebook_path',
27+
'WebFetch': 'url',
28+
'WebSearch': 'query',
29+
}
30+
31+
PROMPT_TEMPLATE = """
32+
{{body}}
33+
34+
The user has selected the following files as attachements:
35+
36+
37+
"""
38+
39+
def input_dict_to_str(d: Dict[str, Any]) -> str:
40+
"""Convert input dictionary to string representation, omitting specified args."""
41+
args = []
42+
for k, v in d.items():
43+
if k not in OMIT_INPUT_ARGS:
44+
args.append(f"{k}={v}")
45+
return ', '.join(args)
46+
47+
48+
def tool_to_str(block: ToolUseBlock, persona_instance=None) -> str:
49+
"""Convert a ToolUseBlock to its string representation."""
50+
results = []
51+
52+
if block.name == 'TodoWrite':
53+
block_id = block.id if hasattr(block, 'id') else str(hash(str(block.input)))
54+
55+
if persona_instance and block_id in persona_instance._printed_todowrite_blocks:
56+
return ""
57+
58+
if persona_instance:
59+
persona_instance._printed_todowrite_blocks.add(block_id)
60+
61+
todos = block.input.get('todos', [])
62+
results.append('TodoWrite()')
63+
for todo in todos:
64+
content = todo.get('content')
65+
if content:
66+
results.append(f"* {content}")
67+
elif block.name in TOOL_PARAM_MAPPING:
68+
param_key = TOOL_PARAM_MAPPING[block.name]
69+
param_value = block.input.get(param_key, '')
70+
results.append(f"🛠️ {block.name}({param_value})")
71+
else:
72+
results.append(f"🛠️ {block.name}({input_dict_to_str(block.input)})")
73+
74+
return '\n'.join(results)
75+
76+
77+
def claude_message_to_str(message, persona_instance=None) -> Optional[str]:
78+
"""Convert a Claude Message to a string by extracting text content."""
79+
text_parts = []
80+
for block in message.content:
81+
if isinstance(block, TextBlock):
82+
text_parts.append(block.text)
83+
elif isinstance(block, ToolUseBlock):
84+
tool_str = tool_to_str(block, persona_instance)
85+
if tool_str:
86+
text_parts.append(tool_str)
87+
else:
88+
text_parts.append(str(block))
89+
return '\n'.join(text_parts) if text_parts else None
90+
91+
92+
class ClaudeCodePersona(BasePersona):
93+
"""Claude Code persona for Jupyter AI integration."""
94+
95+
def __init__(self, *args, **kwargs):
96+
super().__init__(*args, **kwargs)
97+
self._printed_todowrite_blocks = set()
98+
99+
@property
100+
def defaults(self) -> PersonaDefaults:
101+
"""Return default configuration for the Claude Code persona."""
102+
return PersonaDefaults(
103+
name="Claude",
104+
avatar_path="/files/.jupyter/claude.svg",
105+
description="Claude Code",
106+
system_prompt="...",
107+
)
108+
109+
async def _process_response_message(self, message_iterator) -> AsyncIterator[str]:
110+
"""Process response messages from Claude Code SDK."""
111+
async for response_message in message_iterator:
112+
self.log.info(str(response_message))
113+
if isinstance(response_message, AssistantMessage):
114+
msg_str = claude_message_to_str(response_message, self)
115+
if msg_str is not None:
116+
yield msg_str + '\n\n'
117+
118+
def _generate_prompt(self, message: Message) -> str:
119+
attachment_ids = message.attachments
120+
if attachment_ids is None:
121+
return message.body
122+
attachments = self.ychat.get_attachments()
123+
msg_attachments = (attachments[aid] for aid in attachment_ids)
124+
prompt = f"{message.body}\n\n"
125+
prompt += f"The user has attached the following files and may be referring to them in the above prompt:\n\n"
126+
for a in msg_attachments:
127+
if a['type'] == 'file':
128+
prompt += f"file_path={a['value']}"
129+
elif a['type'] == 'notebook':
130+
cells = list(c['id'] for c in a['cells'])
131+
# Claude Code's notebook tools only understand a single cell_id
132+
prompt += f"notebook_path={a['value']} cell_id={cells[0]}"
133+
self.log.info(prompt)
134+
return prompt
135+
136+
async def process_message(self, message: Message) -> None:
137+
"""Process incoming message and stream Claude Code response."""
138+
self._printed_todowrite_blocks.clear()
139+
async_gen = None
140+
prompt = self._generate_prompt(message)
141+
try:
142+
async_gen = query(
143+
prompt=prompt,
144+
options=ClaudeCodeOptions(
145+
max_turns=20,
146+
cwd=self.get_workspace_dir(),
147+
permission_mode='bypassPermissions'
148+
)
149+
)
150+
await self.stream_message(self._process_response_message(async_gen))
151+
except Exception as e:
152+
self.log.error(f"Error in process_message: {e}")
153+
await self.send_message(f"Sorry, I have had an internal error while working on that: {e}")
154+
finally:
155+
if async_gen is not None:
156+
await async_gen.aclose()
Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)